diff --git a/packages/common/js/completion-files/context.md b/packages/common/js/completion-files/context.md
deleted file mode 100644
index a508be21a5..0000000000
--- a/packages/common/js/completion-files/context.md
+++ /dev/null
@@ -1,51 +0,0 @@
-你是一个JavaScript代码补全器,可以使用JS和ES的语法
-
-以下是一些通用的协议:
-常规属性如:{ width: '300px' }
-一. 变量引用
-{ width: { type: 'JSExpression', value: 'this.state.xxx' }
-即当type为JSExpression,取其value并将value的值当做变量调用
-二. 方法引用
-{ onClickNew: { type: 'JSFunction', value: 'function onClickNew() {}' }
-即当type为JSFunction,取其value并将value的值函数调用
-以下是一些依赖,调用均以this.开头:
-1. 数据源
-数据源是定义的数据模型
-const dataSource=$dataSource$
-调用方式为: this.dataSource.xxx
-2. 工具类
-工具类是通用的调用方法或npm依赖
-const utils=$utils$
-调用方式为: this.utils.xxx
-utils有两种类型
-type为npm时,读取content内容,可构造如下引用,例如content中package(依赖包名)为@opentiny/vue,destructuring(解构)为true,exportName(导出组件名称)为Notify,实际引用方式是import { Notify } from '@opentiny/vue';
-type为function时,读取content内容,当content.type为JSFunction则将value视为JS方法并调用,其他可参考通用的协议
-3. 全局变量
-全局变量是使用pinia创建的变量
-const stores=$globalState$
-调用方式为: this.stores.xxx
-4. JS变量
-js变量
-const state=$state$
-调用方式为: this.state.xxx
-5. JS方法
-js方法
-const methods=$methods$
-调用方式为: this.xxx
-
-以上依赖中没有的,则不能调用,如utils中没有axios,则axios不能使用
-
-以下是当前选中的组件
-$currentSchema$
-请理解当前组件,componentName为组件名称,组件包括tinyVue组件、ElementPlus组件,和基本html元素
-对象中的ref属性即vue组件的ref属性,如ref值为testForm,使用方式为this.$('testForm')
-props表示组件的属性,是一个对象,对应vue组件的defineProps和defineEmits中的内容
-props中以on开头的表示其传递的是方法,如onClick,其值可以参考通用协议
-props中没有以on开头的则是普通属性,如tinyInput组件中的placeholder
-props的属性中值为对象,且包含type和value属性,type为JSExpression和JSFunction时,value的值则参考通用协议取用
-
-直接上下文如下:
-$codeBeforeCursor$$codeAfterCursor$
-请从(光标位置)后进行补全
-注意如果是函数时,须以function关键字开头,不使用箭头函数
-请只返回代码,且只返回一个示例,不需要思考过程和解释
\ No newline at end of file
diff --git a/packages/common/js/completion.js b/packages/common/js/completion.js
index d8a1aee624..99011d5398 100644
--- a/packages/common/js/completion.js
+++ b/packages/common/js/completion.js
@@ -9,9 +9,7 @@
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/
-import { ref } from 'vue'
-import { useCanvas, useResource, getMergeMeta, getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register'
-import completion from './completion-files/context.md?raw'
+import { useCanvas, useResource } from '@opentiny/tiny-engine-meta-register'
const keyWords = [
'state',
@@ -173,135 +171,6 @@ const getRange = (position, words) => ({
endColumn: words[words.length - 1].endColumn
})
-const generateBaseReference = () => {
- const { dataSource = [], utils = [], globalState = [] } = useResource().appSchemaState
- const { state, methods } = useCanvas().getPageSchema()
- const currentSchema = useCanvas().getCurrentSchema()
- let referenceContext = completion
- referenceContext = referenceContext.replace('$dataSource$', JSON.stringify(dataSource))
- referenceContext = referenceContext.replace('$utils$', JSON.stringify(utils))
- referenceContext = referenceContext.replace('$globalState$', JSON.stringify(globalState))
- referenceContext = referenceContext.replace('$state$', JSON.stringify(state))
- referenceContext = referenceContext.replace('$methods$', JSON.stringify(methods))
- referenceContext = referenceContext.replace('$currentSchema$', JSON.stringify(currentSchema))
- return referenceContext
-}
-
-const fetchAiInlineCompletion = (codeBeforeCursor, codeAfterCursor) => {
- const { completeModel, apiKey, baseUrl } = getMetaApi(META_SERVICE.Robot).getSelectedQuickModelInfo() || {}
- if (!completeModel || !apiKey || !baseUrl) {
- return
- }
- const referenceContext = generateBaseReference()
- return getMetaApi(META_SERVICE.Http).post(
- '/app-center/api/chat/completions',
- {
- model: completeModel,
- messages: [
- {
- role: 'user',
- content: referenceContext
- .replace('$codeBeforeCursor$', codeBeforeCursor)
- .replace('$codeAfterCursor$', codeAfterCursor)
- }
- ],
- baseUrl,
- stream: false
- },
- {
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${apiKey || ''}`
- }
- }
- )
-}
-
-const initInlineCompletion = (monacoInstance, editorModel) => {
- const requestAllowed = ref(true)
- const timer = ref()
- const inlineCompletionProvider = {
- provideInlineCompletions(model, position, _context, _token) {
- if (editorModel && model.id !== editorModel.id) {
- return new Promise((resolve) => {
- resolve({ items: [] })
- })
- }
-
- if (timer.value) {
- clearTimeout(timer.value)
- }
-
- const words = getWords(model, position)
- const range = getRange(position, words)
- const wordContent = words.map((item) => item.word).join('')
- if (!wordContent || wordContent.lastIndexOf('}') === 0 || wordContent.length < 4) {
- return new Promise((resolve) => {
- resolve({ items: [] })
- })
- }
- if (!requestAllowed.value) {
- return new Promise((resolve) => {
- resolve({
- items: [
- {
- insertText: '',
- range
- }
- ]
- })
- })
- }
- const codeBeforeCursor = model.getValueInRange({
- startLineNumber: 1,
- startColumn: 1,
- endLineNumber: position.lineNumber,
- endColumn: position.column
- })
- const codeAfterCursor = model.getValueInRange({
- startLineNumber: position.lineNumber,
- startColumn: position.column,
- endLineNumber: model.getLineCount(),
- endColumn: model.getLineMaxColumn(model.getLineCount())
- })
- return new Promise((resolve) => {
- // 延迟请求800ms
- timer.value = setTimeout(() => {
- // 节流操作,防止接口一直被请求
- requestAllowed.value = false
- fetchAiInlineCompletion(codeBeforeCursor, codeAfterCursor)
- .then((res) => {
- let insertText = res.choices[0].message.content.trim()
- const wordContentIndex = insertText.indexOf(wordContent)
- if (wordContentIndex === -1) {
- insertText = `${wordContent}${insertText}\n`
- }
- if (wordContentIndex > 0) {
- insertText = insertText.slice(wordContentIndex)
- }
- requestAllowed.value = true
- resolve({
- items: [
- {
- insertText,
- range
- }
- ]
- })
- })
- .catch(() => {
- requestAllowed.value = true
- })
- }, 800)
- })
- },
- freeInlineCompletions() {}
- }
- return ['javascript', 'typescript'].map((lang) =>
- monacoInstance.languages.registerInlineCompletionsProvider(lang, inlineCompletionProvider)
- )
-}
-
export const initCompletion = (monacoInstance, editorModel, conditionFn) => {
const completionItemProvider = {
provideCompletionItems(model, position, _context, _token) {
@@ -331,9 +200,5 @@ export const initCompletion = (monacoInstance, editorModel, conditionFn) => {
const completions = ['javascript', 'typescript'].map((lang) => {
return monacoInstance.languages.registerCompletionItemProvider(lang, completionItemProvider)
})
- const { enableAICompletion } = getMergeMeta('engine.plugins.pagecontroller')?.options || {}
- if (enableAICompletion) {
- return completions.concat(initInlineCompletion(monacoInstance, editorModel))
- }
return completions
}
diff --git a/packages/plugins/robot/src/components/header-extension/robot-setting/RobotSetting.vue b/packages/plugins/robot/src/components/header-extension/robot-setting/RobotSetting.vue
index 038f8aa2e4..f084666ea7 100644
--- a/packages/plugins/robot/src/components/header-extension/robot-setting/RobotSetting.vue
+++ b/packages/plugins/robot/src/components/header-extension/robot-setting/RobotSetting.vue
@@ -58,6 +58,27 @@
>
+
+
+ 代码补全模型
+
+
+
+
+
+
+
服务:
@@ -174,6 +195,7 @@ const {
saveRobotSettingState,
getAllAvailableModels,
getCompactModels,
+ getCompletionModels,
addCustomService,
updateService,
deleteService,
@@ -186,16 +208,52 @@ const getModelValue = (serviceId: string, modelName: string) => {
return serviceId && modelName ? `${serviceId}::${modelName}` : ''
}
+const parseModelValue = (value = '') => {
+ const [serviceId = '', modelName = ''] = value.split('::')
+
+ return {
+ serviceId,
+ modelName
+ }
+}
+
const state = reactive({
activeTab: 'model-selection',
modelSelection: {
defaultModel: getModelValue(robotSettingState.defaultModel.serviceId, robotSettingState.defaultModel.modelName),
- quickModel: getModelValue(robotSettingState.quickModel.serviceId, robotSettingState.quickModel.modelName)
+ quickModel: getModelValue(robotSettingState.quickModel.serviceId, robotSettingState.quickModel.modelName),
+ completionModel: getModelValue(
+ robotSettingState.completionModel.serviceId,
+ robotSettingState.completionModel.modelName
+ )
},
showServiceDialog: false,
editingService: undefined as ModelService | undefined
})
+const syncModelSelection = () => {
+ state.modelSelection.defaultModel = getModelValue(
+ robotSettingState.defaultModel.serviceId,
+ robotSettingState.defaultModel.modelName
+ )
+ state.modelSelection.quickModel = getModelValue(
+ robotSettingState.quickModel.serviceId,
+ robotSettingState.quickModel.modelName
+ )
+ state.modelSelection.completionModel = getModelValue(
+ robotSettingState.completionModel.serviceId,
+ robotSettingState.completionModel.modelName
+ )
+}
+
+const notifyMissingApiKey = (service: ModelService) => {
+ useNotify({
+ type: 'warning',
+ title: '未配置API Key',
+ message: `请先为 ${service.label} 配置API Key`
+ })
+}
+
// 获取所有可用模型选项
const allModelOptions = computed(() => {
return getAllAvailableModels().map((model) => ({
@@ -213,6 +271,13 @@ const compactModelOptions = computed(() => {
}))
})
+const completionModelOptions = computed(() => {
+ return getCompletionModels().map((model) => ({
+ label: model.displayLabel,
+ value: model.value
+ }))
+})
+
// 获取当前选择的默认模型信息
const selectedDefaultModelInfo = computed(() => {
const [serviceId] = state.modelSelection.defaultModel.split('::')
@@ -231,16 +296,15 @@ const handleBack = () => {
}
const handleModelChange = () => {
- const [defaultServiceId, defaultModelName] = state.modelSelection.defaultModel.split('::')
+ const { serviceId: defaultServiceId, modelName: defaultModelName } = parseModelValue(
+ state.modelSelection.defaultModel
+ )
// 检查API Key
const defaultService = getServiceById(defaultServiceId)
if (defaultService && !defaultService.apiKey && !defaultService.allowEmptyApiKey) {
- useNotify({
- type: 'warning',
- title: '未配置API Key',
- message: `请先为 ${defaultService.label} 配置API Key`
- })
+ notifyMissingApiKey(defaultService)
+ syncModelSelection()
state.activeTab = 'services'
return
}
@@ -255,7 +319,16 @@ const handleModelChange = () => {
}
const handleCompactModelChange = () => {
- const [quickServiceId = '', quickModelName = ''] = (state.modelSelection.quickModel || '').split('::')
+ const { serviceId: quickServiceId, modelName: quickModelName } = parseModelValue(state.modelSelection.quickModel)
+ const quickService = getServiceById(quickServiceId)
+
+ if (quickService && !quickService.apiKey && !quickService.allowEmptyApiKey) {
+ notifyMissingApiKey(quickService)
+ syncModelSelection()
+ state.activeTab = 'services'
+ return
+ }
+
const updatedState = {
quickModel: {
serviceId: quickServiceId,
@@ -265,28 +338,86 @@ const handleCompactModelChange = () => {
saveRobotSettingState(updatedState)
}
+const handleCompletionModelChange = () => {
+ const { serviceId: completionServiceId, modelName: completionModelName } = parseModelValue(
+ state.modelSelection.completionModel
+ )
+ const completionService = getServiceById(completionServiceId)
+
+ if (completionService && !completionService.apiKey && !completionService.allowEmptyApiKey) {
+ notifyMissingApiKey(completionService)
+ syncModelSelection()
+ state.activeTab = 'services'
+ return
+ }
+
+ saveRobotSettingState({
+ completionModel: {
+ serviceId: completionServiceId,
+ modelName: completionModelName
+ }
+ })
+}
+
const addService = () => {
state.editingService = undefined
state.showServiceDialog = true
}
-const editService = (service: ModelService) => {
+const editService = (service: any) => {
state.editingService = JSON.parse(JSON.stringify(service))
state.showServiceDialog = true
}
const handleDeleteService = (serviceId: string) => {
deleteService(serviceId)
+
+ const shouldResetDefaultModel = robotSettingState.defaultModel.serviceId === serviceId
+ const shouldResetQuickModel = robotSettingState.quickModel.serviceId === serviceId
+ const shouldResetCompletionModel = robotSettingState.completionModel.serviceId === serviceId
+
+ if (shouldResetDefaultModel || shouldResetQuickModel || shouldResetCompletionModel) {
+ saveRobotSettingState({
+ ...(shouldResetDefaultModel
+ ? {
+ defaultModel: {
+ serviceId: '',
+ modelName: ''
+ }
+ }
+ : {}),
+ ...(shouldResetCompletionModel
+ ? {
+ completionModel: {
+ serviceId: '',
+ modelName: ''
+ }
+ }
+ : {}),
+ ...(shouldResetQuickModel
+ ? {
+ quickModel: {
+ serviceId: '',
+ modelName: ''
+ }
+ }
+ : {})
+ })
+ }
+
+ syncModelSelection()
}
-const handleServiceConfirm = (serviceData: Partial) => {
+const handleServiceConfirm = async (serviceData: Partial) => {
if (serviceData.id) {
// 更新现有服务
- updateService(serviceData.id, serviceData)
+ await updateService(serviceData.id, serviceData)
} else {
// 添加新服务
- addCustomService(serviceData as any)
+ await addCustomService(serviceData as any)
}
+
+ syncModelSelection()
}
diff --git a/packages/plugins/robot/src/composables/core/useConfig.ts b/packages/plugins/robot/src/composables/core/useConfig.ts
index 01908a89a0..822c302712 100644
--- a/packages/plugins/robot/src/composables/core/useConfig.ts
+++ b/packages/plugins/robot/src/composables/core/useConfig.ts
@@ -15,11 +15,17 @@ import { reactive, readonly } from 'vue'
import { DEFAULT_LLM_MODELS } from '../../constants'
import { getRobotServiceOptions } from '../../utils'
import { ChatMode } from '../../types/mode.types'
-import type { ModelConfig, ModelService, RobotSettings, SelectedModelInfo } from '../../types/setting.types'
+import type {
+ CompletionProtocol,
+ ModelConfig,
+ ModelService,
+ RobotSettings,
+ SelectedModelInfo
+} from '../../types/setting.types'
import apiService from '../../services/api'
const SETTING_STORAGE_KEY = 'tiny-engine-robot-settings'
-const SETTING_VERSION = 2 // 新版本号
+const SETTING_VERSION = 3 // 新版本号
const robotSettingState = reactive({
version: SETTING_VERSION,
@@ -31,6 +37,10 @@ const robotSettingState = reactive({
serviceId: '',
modelName: ''
},
+ completionModel: {
+ serviceId: '',
+ modelName: ''
+ },
services: [],
chatMode: ChatMode.Agent,
enableThinking: false
@@ -44,7 +54,113 @@ const getAIModelOptions = () => {
return mergeAIModelOptions(DEFAULT_LLM_MODELS, customAIModels) // eslint-disable-line
}
+const QWEN_FIM_MODEL_PATTERNS = [/^qwen-coder-turbo(?:-latest|-0919)?$/, /^qwen2\.5-coder-(7|14|32)b-instruct$/]
+const DEEPSEEK_FIM_MODELS = new Set(['deepseek-chat', 'deepseek-coder'])
+
+const matchesCompletionModel = (modelName = '', patterns: RegExp[] = [], exactModels: Set = new Set()) => {
+ const normalizedModelName = modelName.toLowerCase()
+
+ if (!normalizedModelName) {
+ return false
+ }
+
+ if (exactModels.has(normalizedModelName)) {
+ return true
+ }
+
+ return patterns.some((pattern) => pattern.test(normalizedModelName))
+}
+
+const inferCompletionProtocol = ({
+ provider = '',
+ baseUrl = '',
+ modelName = '',
+ capabilities = {}
+}: {
+ provider?: string
+ baseUrl?: string
+ modelName?: string
+ capabilities?: ModelConfig['capabilities']
+}): CompletionProtocol | null => {
+ if (capabilities?.completionProtocol) {
+ return capabilities.completionProtocol
+ }
+
+ if (matchesCompletionModel(modelName, QWEN_FIM_MODEL_PATTERNS)) {
+ return 'qwen'
+ }
+
+ if (matchesCompletionModel(modelName, [], DEEPSEEK_FIM_MODELS)) {
+ return 'deepseek'
+ }
+
+ const normalizedProvider = provider.toLowerCase()
+ const normalizedBaseUrl = baseUrl.toLowerCase()
+ if (
+ normalizedProvider === 'deepseek' &&
+ normalizedBaseUrl.includes('deepseek.com') &&
+ matchesCompletionModel(modelName, [], DEEPSEEK_FIM_MODELS)
+ ) {
+ return 'deepseek'
+ }
+
+ return null
+}
+
// 初始化内置服务
+const isCompletionCapableModel = (service: ModelService | undefined, model: ModelConfig | undefined) => {
+ if (!service || !model) {
+ return false
+ }
+
+ return (
+ inferCompletionProtocol({
+ provider: service.provider,
+ baseUrl: service.baseUrl,
+ modelName: model.name,
+ capabilities: model.capabilities
+ }) !== null
+ )
+}
+
+const getFallbackCompletionModel = (services: ModelService[], preferredServiceId = '') => {
+ const preferredService = services.find((service) => service.id === preferredServiceId)
+ const orderedServices = preferredService
+ ? [preferredService, ...services.filter((service) => service.id !== preferredServiceId)]
+ : services
+
+ for (const service of orderedServices) {
+ const supportedModel = service.models.find((model) => isCompletionCapableModel(service, model))
+ if (supportedModel) {
+ return {
+ serviceId: service.id,
+ modelName: supportedModel.name
+ }
+ }
+ }
+
+ return {
+ serviceId: '',
+ modelName: ''
+ }
+}
+
+const resolveCompletionModelSelection = (services: ModelService[], serviceId = '', modelName = '') => {
+ if (serviceId && modelName) {
+ const selectedService = services.find((service) => service.id === serviceId)
+ const selectedModel = selectedService?.models.find((model) => model.name === modelName)
+
+ if (isCompletionCapableModel(selectedService, selectedModel)) {
+ return {
+ serviceId,
+ modelName
+ }
+ }
+ }
+
+ return getFallbackCompletionModel(services, serviceId)
+}
+
const initBuiltInServices = (): ModelService[] => {
return getAIModelOptions().map((service: any) => ({
id: service.provider,
@@ -74,6 +190,7 @@ const initDefaultSettings = (): RobotSettings => {
serviceId: '',
modelName: ''
},
+ completionModel: getFallbackCompletionModel(builtInServices),
services: builtInServices,
chatMode: ChatMode.Agent,
enableThinking: false
@@ -120,16 +237,29 @@ const migrateOldSettings = (oldSettings: any): RobotSettings | null => {
customService.models.push({
name: customizeModel.completeModel,
label: customizeModel.completeModel,
- capabilities: { compact: true }
+ capabilities: {
+ compact: true
+ }
})
}
services.push(customService)
}
- // 确定默认模型和快速模型
+ // 确定默认模型、快速模型和代码补全模型
const selectedModel = activeName === 'existingModels' ? existModel : customizeModel
const defaultServiceId =
activeName === 'existingModels' ? services.find((s) => s.baseUrl === selectedModel?.baseUrl)?.id : ''
+ const legacyCompleteModelName = selectedModel?.completeModel || ''
+
+ const quickModel = {
+ serviceId: defaultServiceId || '',
+ modelName: legacyCompleteModelName
+ }
+ const completionModel = resolveCompletionModelSelection(
+ services,
+ defaultServiceId || services[0]?.id || '',
+ legacyCompleteModelName
+ )
return {
version: SETTING_VERSION,
@@ -137,10 +267,8 @@ const migrateOldSettings = (oldSettings: any): RobotSettings | null => {
serviceId: defaultServiceId || services[0]?.id || '',
modelName: selectedModel?.model || services[0]?.models[0]?.name || ''
},
- quickModel: {
- serviceId: defaultServiceId || '',
- modelName: selectedModel?.completeModel || ''
- },
+ quickModel,
+ completionModel,
services,
chatMode: chatMode || ChatMode.Agent,
enableThinking: enableThinking || false
@@ -323,6 +451,22 @@ const getCompactModels = () => {
return getAllAvailableModels().filter((model) => model.capabilities?.compact)
}
+const getCompletionModels = () => {
+ return robotSettingState.services.flatMap((service) =>
+ service.models
+ .filter((model) => isCompletionCapableModel(service, model))
+ .map((model) => ({
+ serviceId: service.id,
+ serviceName: service.label,
+ modelName: model.name,
+ modelLabel: model.label,
+ capabilities: model.capabilities || {},
+ displayLabel: `${service.label} - ${model.label}`,
+ value: `${service.id}::${model.name}`
+ }))
+ )
+}
+
const updateThinkingState = (value: boolean) => {
robotSettingState.enableThinking = value
saveRobotSettingState({ enableThinking: robotSettingState.enableThinking })
@@ -440,6 +584,35 @@ const getSelectedQuickModelInfo = (): SelectedModelInfo => {
}
}
+const getSelectedCompletionModelInfo = (): SelectedModelInfo => {
+ const currentService: ModelService | undefined = getServiceById(robotSettingState.completionModel.serviceId)
+ const currentModel: ModelConfig | undefined = currentService?.models.find(
+ (m) => m.name === robotSettingState.completionModel.modelName
+ )
+ const { name = '', label = '', capabilities = {} } = currentModel || {}
+ const completionProtocol =
+ inferCompletionProtocol({
+ provider: currentService?.provider,
+ baseUrl: currentService?.baseUrl,
+ modelName: name,
+ capabilities
+ }) || null
+
+ const { models, ...service } = currentService ?? ({} as Partial)
+
+ return {
+ name,
+ label,
+ capabilities,
+ service: (currentService ? service : null) as ModelService | null,
+ model: robotSettingState.completionModel.modelName,
+ completeModel: robotSettingState.completionModel.modelName || '',
+ completionProtocol,
+ baseUrl: currentService?.baseUrl || '',
+ apiKey: currentService?.apiKey || ''
+ }
+}
+
export default () => {
return {
// 配置状态
@@ -456,8 +629,10 @@ export default () => {
getModelCapabilities,
getAllAvailableModels,
getCompactModels,
+ getCompletionModels,
getSelectedModelInfo, // 对话模型信息
getSelectedQuickModelInfo, // 快速模型信息
+ getSelectedCompletionModelInfo, // 代码补全模型信息
// 服务管理
addCustomService,
diff --git a/packages/plugins/robot/src/constants/model-config.ts b/packages/plugins/robot/src/constants/model-config.ts
index 4458339325..a2bcb348ec 100644
--- a/packages/plugins/robot/src/constants/model-config.ts
+++ b/packages/plugins/robot/src/constants/model-config.ts
@@ -89,6 +89,15 @@ export const DEFAULT_LLM_MODELS = [
jsonOutput: bailianJsonOutputExtraBody
}
},
+ {
+ label: 'Qwen Coder编程模型(Turbo)',
+ name: 'qwen-coder-turbo-latest',
+ capabilities: {
+ toolCalling: true,
+ jsonOutput: bailianJsonOutputExtraBody,
+ completionProtocol: 'qwen'
+ }
+ },
{
label: 'Qwen3(14b)',
name: 'qwen3-14b',
@@ -112,6 +121,7 @@ export const DEFAULT_LLM_MODELS = [
name: 'deepseek-chat',
capabilities: {
toolCalling: true,
+ completionProtocol: 'deepseek',
reasoning: {
extraBody: {
enable: { model: 'deepseek-reasoner' },
diff --git a/packages/plugins/robot/src/types/setting.types.ts b/packages/plugins/robot/src/types/setting.types.ts
index 9699afedc6..5ab0017d45 100644
--- a/packages/plugins/robot/src/types/setting.types.ts
+++ b/packages/plugins/robot/src/types/setting.types.ts
@@ -14,6 +14,8 @@
* 模型配置接口
*/
+export type CompletionProtocol = 'qwen' | 'deepseek'
+
export interface Capability {
extraBody: {
enable: Record | null
@@ -30,6 +32,7 @@ export interface ModelConfig {
vision?: boolean
reasoning?: boolean | Capability
compact?: boolean
+ completionProtocol?: CompletionProtocol
jsonOutput?: boolean | Capability
}
}
@@ -63,6 +66,7 @@ export interface RobotSettings {
version?: number
defaultModel: ModelSelection
quickModel: ModelSelection
+ completionModel: ModelSelection
services: ModelService[]
chatMode: string
enableThinking: boolean
@@ -80,6 +84,7 @@ export type SelectedModelInfo = ModelConfig & {
// 模型兼容字段
model?: string
completeModel?: string
+ completionProtocol?: CompletionProtocol | null
// 服务兼容字段
baseUrl?: string
apiKey?: string
diff --git a/packages/plugins/script/meta.js b/packages/plugins/script/meta.js
index 6991805f54..779a029fb4 100644
--- a/packages/plugins/script/meta.js
+++ b/packages/plugins/script/meta.js
@@ -6,7 +6,8 @@ export default {
width: 600,
widthResizable: true,
options: {
- enableAICompletion: true
+ aiCompletionEnabled: true
+ // aiCompletionTrigger: 'onIdle' // 可选:触发模式 'onIdle'(默认) | 'onTyping' | 'onDemand'
},
confirm: 'close' // 当点击插件栏切换或关闭前是否需要确认, 会调用插件中confirm值指定的方法,e.g. 此处指向 close方法,会调用插件的close方法执行确认逻辑
}
diff --git a/packages/plugins/script/package.json b/packages/plugins/script/package.json
index ccb85614e7..2dfcd073fb 100644
--- a/packages/plugins/script/package.json
+++ b/packages/plugins/script/package.json
@@ -27,7 +27,8 @@
"dependencies": {
"@opentiny/tiny-engine-common": "workspace:*",
"@opentiny/tiny-engine-meta-register": "workspace:*",
- "@opentiny/tiny-engine-utils": "workspace:*"
+ "@opentiny/tiny-engine-utils": "workspace:*",
+ "monacopilot": "^1.2.12"
},
"devDependencies": {
"@opentiny/tiny-engine-vite-plugin-meta-comments": "workspace:*",
diff --git a/packages/plugins/script/src/Main.vue b/packages/plugins/script/src/Main.vue
index de663b7112..7e98a63cfd 100644
--- a/packages/plugins/script/src/Main.vue
+++ b/packages/plugins/script/src/Main.vue
@@ -34,11 +34,14 @@
/* metaService: engine.plugins.pagecontroller.Main */
import { onBeforeUnmount, reactive, provide } from 'vue'
import { Button } from '@opentiny/vue'
+import { registerCompletion, type CompletionRegistration, type RegisterCompletionOptions } from 'monacopilot'
import { VueMonaco, PluginPanel } from '@opentiny/tiny-engine-common'
-import { useHelp, useLayout } from '@opentiny/tiny-engine-meta-register'
+import { useHelp, useLayout, getMergeMeta } from '@opentiny/tiny-engine-meta-register'
import { initCompletion } from '@opentiny/tiny-engine-common/js/completion'
import { initLinter } from '@opentiny/tiny-engine-common/js/linter'
import useMethod, { saveMethod, highlightMethod, getMethodNameList, getMethods } from './js/method'
+import { createCompletionHandler } from './ai-completion/adapters/index'
+import { shouldTriggerCompletion } from './ai-completion/triggers/completionTrigger'
export const api = {
saveMethod,
@@ -59,13 +62,18 @@ export default {
}
},
emits: ['close'],
- setup(props, { emit }) {
+ setup(_props, { emit }) {
const docsUrl = useHelp().getDocsUrl('script')
const docsContent = '同一页面/区块的添加事件会统一保存到对应的页面JS中。'
const { state, monaco, change, close, saveMethods } = useMethod({ emit })
const { PLUGIN_NAME } = useLayout()
+ type RequestHandler = NonNullable
+ type TriggerMode = NonNullable
+ let completion: CompletionRegistration | null = null
+ let completionAction: { dispose?: () => void } | null = null
+
const panelState = reactive({
emitEvent: emit
})
@@ -101,24 +109,74 @@ export default {
wordWrapStrategy: 'advanced'
}
- const editorDidMount = (editor) => {
- if (!monaco.value) {
- return
+ const editorDidMount = (editor: any) => {
+ const monacoRef = monaco as any
+ if (!monacoRef.value) return
+
+ // 保留原有的 Lowcode API 提示
+ state.completionProvider = initCompletion(
+ monacoRef.value.getMonaco(),
+ monacoRef.value.getEditor()?.getModel()
+ ) as any
+
+ // 保留原有的 ESLint
+ state.linterWorker = initLinter(editor, monacoRef.value.getMonaco(), state) as any
+
+ const pageControllerOptions = getMergeMeta('engine.plugins.pagecontroller')?.options || {}
+ const aiCompletionEnabled = pageControllerOptions.aiCompletionEnabled ?? pageControllerOptions.enableAICompletion
+ const aiCompletionTrigger = pageControllerOptions.aiCompletionTrigger || 'onIdle'
+
+ if (aiCompletionEnabled) {
+ try {
+ const monacoInstance = monacoRef.value.getMonaco()
+ const editorInstance = monacoRef.value.getEditor()
+
+ completion?.deregister?.()
+ completionAction?.dispose?.()
+
+ completion = registerCompletion(monacoInstance, editorInstance, {
+ language: 'javascript',
+ filename: 'page.js',
+ maxContextLines: 50,
+ enableCaching: true,
+ allowFollowUpCompletions: false,
+ trigger: aiCompletionTrigger as TriggerMode,
+ triggerIf: ({ text, position }) => {
+ return shouldTriggerCompletion({
+ text,
+ position
+ })
+ },
+ requestHandler: createCompletionHandler() as RequestHandler
+ })
+
+ completionAction = monacoInstance.editor.addEditorAction({
+ id: 'monacopilot.triggerCompletion',
+ label: 'Complete Code',
+ contextMenuGroupId: 'navigation',
+ keybindings: [monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyMod.Shift | monacoInstance.KeyCode.Space],
+ run: () => {
+ completion!.trigger()
+ }
+ })
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('❌ AI 补全注册失败:', error)
+ }
}
-
- // Lowcode API 提示
- state.completionProvider = initCompletion(monaco.value.getMonaco(), monaco.value.getEditor()?.getModel())
-
- // 初始化 ESLint worker
- state.linterWorker = initLinter(editor, monaco.value.getMonaco(), state)
}
onBeforeUnmount(() => {
- state.completionProvider?.forEach((provider) => {
- provider.dispose()
+ // 清理 AI 补全
+ if (completion) {
+ completion.deregister()
+ }
+ completionAction?.dispose?.()
+ ;(state.completionProvider as any)?.forEach?.((provider: any) => {
+ provider?.dispose?.()
})
// 终止 ESLint worker
- state.linterWorker?.terminate?.()
+ ;(state.linterWorker as any)?.terminate?.()
})
return {
diff --git a/packages/plugins/script/src/ai-completion/adapters/deepseekAdapter.js b/packages/plugins/script/src/ai-completion/adapters/deepseekAdapter.js
new file mode 100644
index 0000000000..5f68ea3d87
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/adapters/deepseekAdapter.js
@@ -0,0 +1,99 @@
+import { HTTP_CONFIG, ERROR_MESSAGES, DEEPSEEK_CONFIG } from '../constants.js'
+import { fetchWithTimeout } from '../utils/requestUtils.js'
+
+export function buildDeepSeekCompletionsUrl(baseUrl) {
+ const normalizedBaseUrl = String(baseUrl || '').trim()
+
+ if (!normalizedBaseUrl) {
+ return ''
+ }
+
+ const normalizePath = (path = '') => {
+ const trimmedPath = path.replace(/\/+$/, '')
+
+ if (trimmedPath.endsWith('/beta/completions')) {
+ return trimmedPath
+ }
+
+ if (trimmedPath.endsWith('/beta')) {
+ return `${trimmedPath}/completions`
+ }
+
+ if (/\/v1$/i.test(trimmedPath)) {
+ return trimmedPath.replace(/\/v1$/i, '/beta/completions')
+ }
+
+ return `${trimmedPath}/beta/completions`
+ }
+
+ try {
+ const parsedUrl = new URL(normalizedBaseUrl)
+ parsedUrl.pathname = normalizePath(parsedUrl.pathname)
+ return parsedUrl.toString()
+ } catch {
+ return normalizePath(normalizedBaseUrl)
+ }
+}
+
+/**
+ * 构建 DeepSeek FIM 格式的请求参数
+ * @param {string} fileContent - 文件内容(包含 [CURSOR] 标记)
+ * @param {Object} fimBuilder - FIM 构建器实例
+ * @param {Object} metadata - 元数据(language, lowcodeMetadata 等)
+ * @returns {{ prompt: string, suffix: string, cursorContext: Object }} FIM 参数和上下文
+ */
+export function buildDeepSeekFIMParams(fileContent, fimBuilder, metadata = {}) {
+ const { prefix, suffix, cursorContext } = fimBuilder.buildFIMComponents(fileContent, metadata)
+
+ return {
+ prompt: prefix,
+ suffix,
+ cursorContext
+ }
+}
+
+/**
+ * 调用 DeepSeek FIM Completions API
+ * @param {string} prompt - 前缀内容
+ * @param {string} suffix - 后缀内容
+ * @param {Object} config - 配置对象
+ * @param {string} apiKey - API 密钥
+ * @param {string} baseUrl - 基础 URL
+ * @returns {Promise} 补全文本
+ */
+export async function callDeepSeekAPI(prompt, suffix, config, apiKey, baseUrl, signal) {
+ const completionsUrl = buildDeepSeekCompletionsUrl(baseUrl)
+
+ const requestBody = {
+ model: config.model,
+ prompt,
+ suffix,
+ max_tokens: config.maxTokens || DEEPSEEK_CONFIG.FIM.MAX_TOKENS,
+ temperature: DEEPSEEK_CONFIG.DEFAULT_TEMPERATURE,
+ top_p: DEEPSEEK_CONFIG.TOP_P,
+ stream: HTTP_CONFIG.STREAM,
+ stop: config.stopSequences
+ }
+
+ const fetchResponse = await fetchWithTimeout(
+ completionsUrl,
+ {
+ method: HTTP_CONFIG.METHOD,
+ headers: {
+ 'Content-Type': HTTP_CONFIG.CONTENT_TYPE,
+ Authorization: `Bearer ${apiKey}`
+ },
+ body: JSON.stringify(requestBody)
+ },
+ HTTP_CONFIG.REQUEST_TIMEOUT_MS,
+ signal
+ )
+
+ if (!fetchResponse.ok) {
+ const errorText = await fetchResponse.text()
+ throw new Error(`${ERROR_MESSAGES.REQUEST_FAILED} ${fetchResponse.status}: ${errorText}`)
+ }
+
+ const response = await fetchResponse.json()
+ return response?.choices?.[0]?.text
+}
diff --git a/packages/plugins/script/src/ai-completion/adapters/index.js b/packages/plugins/script/src/ai-completion/adapters/index.js
new file mode 100644
index 0000000000..3d705f3a8b
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/adapters/index.js
@@ -0,0 +1,158 @@
+import { getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register'
+import { createSmartPrompt } from '../builders/promptBuilder.js'
+import { FIMPromptBuilder } from '../builders/fimPromptBuilder.js'
+import { detectModelType, calculateTokens, getStopSequences } from '../utils/modelUtils.js'
+import { cleanCompletion, buildLowcodeMetadata } from '../utils/completionUtils.js'
+import { buildQwenFIMPrompt, callQwenAPI } from './qwenAdapter.js'
+import { buildDeepSeekFIMParams, callDeepSeekAPI } from './deepseekAdapter.js'
+import { QWEN_CONFIG, DEEPSEEK_CONFIG, DEFAULTS, ERROR_MESSAGES, MODEL_CONFIG } from '../constants.js'
+
+/**
+ * 创建请求处理器
+ * @returns {Function} 请求处理函数
+ */
+export function createCompletionHandler() {
+ // 为不同模型创建 FIM 构建器
+ const qwenFimBuilder = new FIMPromptBuilder(QWEN_CONFIG)
+ const deepseekFimBuilder = new FIMPromptBuilder(DEEPSEEK_CONFIG)
+
+ return async (params) => {
+ try {
+ const requestSignal = params?.signal ?? null
+
+ // 1. 获取 AI 配置
+ const {
+ completeModel,
+ apiKey,
+ baseUrl,
+ capabilities = {},
+ service = null
+ } = getMetaApi(META_SERVICE.Robot).getSelectedCompletionModelInfo() || {}
+
+ if (!completeModel || !baseUrl) {
+ return {
+ completion: null,
+ error: ERROR_MESSAGES.CONFIG_MISSING
+ }
+ }
+
+ if (!apiKey && !service?.allowEmptyApiKey) {
+ return {
+ completion: null,
+ error: ERROR_MESSAGES.API_KEY_MISSING
+ }
+ }
+
+ // 2. 提取代码上下文
+ const {
+ textBeforeCursor = '',
+ textAfterCursor = '',
+ language = DEFAULTS.LANGUAGE,
+ filename
+ } = params.body?.completionMetadata || {}
+
+ // 3. 构建低代码元数据和 prompt
+ const lowcodeMetadata = buildLowcodeMetadata()
+ const { fileContent, commentStatus, lowcodeContext } = createSmartPrompt({
+ textBeforeCursor,
+ textAfterCursor,
+ language,
+ filename,
+ lowcodeMetadata
+ })
+
+ // 4. 检测模型类型并构建 FIM 参数
+ const modelType = detectModelType(completeModel, {
+ provider: service?.provider,
+ baseUrl,
+ capabilities
+ })
+
+ if (modelType === MODEL_CONFIG.UNKNOWN.TYPE) {
+ return {
+ completion: null,
+ error: ERROR_MESSAGES.UNSUPPORTED_MODEL
+ }
+ }
+
+ // 5. 准备元数据(用于增强 FIM prompt)
+ const fimMetadata = {
+ language,
+ isComment: commentStatus.isComment,
+ lowcodeContext
+ }
+
+ // 6. 根据模型类型构建请求参数
+ let completionText
+ let cursorContext
+
+ if (modelType === MODEL_CONFIG.QWEN.TYPE) {
+ // ===== Qwen 流程 =====
+ const { prompt, cursorContext: ctx } = buildQwenFIMPrompt(fileContent, qwenFimBuilder, fimMetadata)
+ cursorContext = ctx
+
+ completionText = await callQwenAPI(
+ prompt,
+ {
+ model: completeModel,
+ maxTokens: calculateTokens(ctx),
+ stopSequences: getStopSequences(ctx)
+ },
+ apiKey,
+ baseUrl,
+ requestSignal
+ )
+ } else {
+ // ===== DeepSeek 流程(使用 FIM API) =====
+ const {
+ prompt,
+ suffix,
+ cursorContext: ctx
+ } = buildDeepSeekFIMParams(fileContent, deepseekFimBuilder, fimMetadata)
+ cursorContext = ctx
+
+ completionText = await callDeepSeekAPI(
+ prompt,
+ suffix,
+ {
+ model: completeModel,
+ maxTokens: calculateTokens(ctx),
+ stopSequences: getStopSequences(ctx)
+ },
+ apiKey,
+ baseUrl,
+ requestSignal
+ )
+ }
+
+ // 7. 处理补全结果
+ if (completionText) {
+ completionText = cleanCompletion(completionText, cursorContext, textAfterCursor)
+
+ if (!completionText) {
+ return {
+ completion: null,
+ error: ERROR_MESSAGES.NO_COMPLETION
+ }
+ }
+
+ return {
+ completion: completionText,
+ error: null
+ }
+ }
+
+ return {
+ completion: null,
+ error: ERROR_MESSAGES.NO_COMPLETION
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('❌ AI 补全请求失败:', error)
+ return {
+ completion: null,
+ error: error.message || ERROR_MESSAGES.REQUEST_FAILED
+ }
+ }
+ }
+}
diff --git a/packages/plugins/script/src/ai-completion/adapters/qwenAdapter.js b/packages/plugins/script/src/ai-completion/adapters/qwenAdapter.js
new file mode 100644
index 0000000000..ba5c6e2d0e
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/adapters/qwenAdapter.js
@@ -0,0 +1,64 @@
+import { QWEN_CONFIG, HTTP_CONFIG, ERROR_MESSAGES } from '../constants.js'
+import { fetchWithTimeout } from '../utils/requestUtils.js'
+
+/**
+ * 构建 Qwen FIM prompt
+ * @param {string} fileContent - 文件内容(包含 [CURSOR] 标记)
+ * @param {Object} fimBuilder - FIM 构建器实例
+ * @param {Object} metadata - 元数据
+ * @returns {{ prompt: string, cursorContext: Object }} Prompt 和上下文
+ */
+export function buildQwenFIMPrompt(fileContent, fimBuilder, metadata = {}) {
+ const { fimPrompt, cursorContext } = fimBuilder.buildOptimizedFIMPrompt(fileContent, metadata)
+
+ return {
+ prompt: fimPrompt,
+ cursorContext
+ }
+}
+
+/**
+ * 调用 Qwen Completions API
+ * @param {string} prompt - FIM prompt
+ * @param {Object} config - 配置对象
+ * @param {string} apiKey - API 密钥
+ * @param {string} baseUrl - 基础 URL
+ * @returns {Promise} 补全文本
+ */
+export async function callQwenAPI(prompt, config, apiKey, baseUrl, signal) {
+ // 构建完整的 Completions API URL
+ const completionsUrl = `${baseUrl}${QWEN_CONFIG.COMPLETION_PATH}`
+
+ const requestBody = {
+ model: config.model,
+ prompt,
+ max_tokens: config.maxTokens,
+ temperature: QWEN_CONFIG.DEFAULT_TEMPERATURE,
+ top_p: QWEN_CONFIG.TOP_P,
+ stream: HTTP_CONFIG.STREAM,
+ stop: config.stopSequences,
+ presence_penalty: QWEN_CONFIG.PRESENCE_PENALTY
+ }
+
+ const fetchResponse = await fetchWithTimeout(
+ completionsUrl,
+ {
+ method: HTTP_CONFIG.METHOD,
+ headers: {
+ 'Content-Type': HTTP_CONFIG.CONTENT_TYPE,
+ Authorization: `Bearer ${apiKey}`
+ },
+ body: JSON.stringify(requestBody)
+ },
+ HTTP_CONFIG.REQUEST_TIMEOUT_MS,
+ signal
+ )
+
+ if (!fetchResponse.ok) {
+ const errorText = await fetchResponse.text()
+ throw new Error(`${ERROR_MESSAGES.QWEN_API_ERROR} ${fetchResponse.status}: ${errorText}`)
+ }
+
+ const response = await fetchResponse.json()
+ return response?.choices?.[0]?.text
+}
diff --git a/packages/plugins/script/src/ai-completion/builders/fimPromptBuilder.js b/packages/plugins/script/src/ai-completion/builders/fimPromptBuilder.js
new file mode 100644
index 0000000000..c0eb28a4ea
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/builders/fimPromptBuilder.js
@@ -0,0 +1,255 @@
+import { FIM_CONFIG } from '../constants.js'
+import {
+ createCodeInstruction,
+ createLowcodeInstruction,
+ BLOCK_COMMENT_INSTRUCTION,
+ LINE_COMMENT_INSTRUCTION
+} from '../prompts/templates.js'
+import { getCommentState, getOpenScopeContext, sanitizeStructuralText } from '../utils/contextAnalysis.js'
+
+/**
+ * FIM (Fill-In-the-Middle) Prompt 构建器
+ * 用于处理 FIM 格式的代码补全
+ */
+export class FIMPromptBuilder {
+ constructor(config) {
+ this.config = config
+ }
+
+ /**
+ * 构建增强的 FIM 组件(包含完整指令)
+ * @param {string} fileContent - 文件内容,包含 [CURSOR] 标记
+ * @param {Object} metadata - 元数据(language, isComment, lowcodeContext 等)
+ * @returns {{ prefix: string, suffix: string, cursorContext: Object }} FIM 组件
+ */
+ buildFIMComponents(fileContent, metadata = {}) {
+ const { language = 'javascript', isComment = false, lowcodeContext = null } = metadata
+
+ // 1. 查找光标位置
+ const cursorIndex = fileContent.indexOf(FIM_CONFIG.MARKERS.CURSOR)
+
+ if (cursorIndex === -1) {
+ return {
+ prefix: fileContent,
+ suffix: '',
+ cursorContext: { type: 'unknown' }
+ }
+ }
+
+ // 2. 分割前缀和后缀
+ const rawPrefix = fileContent.substring(0, cursorIndex)
+ const rawSuffix = fileContent.substring(cursorIndex + FIM_CONFIG.MARKERS.CURSOR.length)
+
+ // 3. 分析光标上下文
+ const cursorContext = this.analyzeCursorContext(rawPrefix)
+
+ // 4. 构建完整的指令前缀
+ const instructionPrefix = this.buildInstructionPrefix(language, isComment, lowcodeContext, cursorContext)
+
+ // 5. 优化前缀和后缀
+ const optimizedPrefix = this.optimizePrefix(rawPrefix)
+ const optimizedSuffix = this.optimizeSuffix(rawSuffix)
+
+ // 6. 组合:指令 + 代码前缀
+ const fullPrefix = instructionPrefix + optimizedPrefix
+
+ return {
+ prefix: fullPrefix,
+ suffix: optimizedSuffix,
+ cursorContext
+ }
+ }
+
+ /**
+ * 构建指令前缀(将 system prompt 和 instruction 转换为注释形式)
+ * @param {string} language - 编程语言
+ * @param {boolean} isComment - 是否在注释中
+ * @param {Object} lowcodeContext - 低代码上下文
+ * @param {Object} cursorContext - 光标上下文
+ * @returns {string} 指令前缀
+ */
+ buildInstructionPrefix(language, isComment, lowcodeContext, cursorContext) {
+ let specificInstruction
+ if (isComment) {
+ specificInstruction = cursorContext.inBlockComment ? BLOCK_COMMENT_INSTRUCTION : LINE_COMMENT_INSTRUCTION
+ } else if (lowcodeContext) {
+ specificInstruction = createLowcodeInstruction(language, lowcodeContext)
+ } else {
+ specificInstruction = createCodeInstruction(language)
+ }
+
+ return `${this.convertToComments(specificInstruction)}\n\n`
+ }
+
+ /**
+ * 将多行文本转换为注释格式
+ * @param {string} text - 原始文本
+ * @returns {string} 注释格式的文本
+ */
+ convertToComments(text) {
+ return text
+ .split('\n')
+ .map((line) => (line.trim() ? `// ${line}` : '//'))
+ .join('\n')
+ }
+
+ /**
+ * 构建优化的 FIM (Fill In the Middle) Prompt(Qwen 格式)
+ * @param {string} fileContent - 文件内容,包含 [CURSOR] 标记
+ * @param {Object} metadata - 元数据
+ * @returns {{ fimPrompt: string, cursorContext: Object }} FIM prompt 和上下文信息
+ */
+ buildOptimizedFIMPrompt(fileContent, metadata = {}) {
+ const { prefix, suffix, cursorContext } = this.buildFIMComponents(fileContent, metadata)
+
+ // 构建 Qwen FIM prompt
+ let fimPrompt
+ if (suffix.trim().length > 0) {
+ // 有后缀:使用 prefix + suffix + middle 模式
+ fimPrompt = `${FIM_CONFIG.MARKERS.PREFIX}${prefix}${FIM_CONFIG.MARKERS.SUFFIX}${suffix}${FIM_CONFIG.MARKERS.MIDDLE}`
+ } else {
+ // 无后缀:只使用 prefix + suffix 模式
+ fimPrompt = `${FIM_CONFIG.MARKERS.PREFIX}${prefix}${FIM_CONFIG.MARKERS.SUFFIX}`
+ }
+
+ return { fimPrompt, cursorContext }
+ }
+
+ /**
+ * 分析光标上下文
+ * @param {string} prefix - 前缀代码
+ * @returns {Object} 上下文信息
+ */
+ analyzeCursorContext(prefix) {
+ const context = {
+ type: 'unknown',
+ inFunction: false,
+ inClass: false,
+ inObject: false,
+ inBlockComment: false,
+ inLineComment: false,
+ needsExpression: false,
+ needsStatement: false
+ }
+
+ const commentState = getCommentState(prefix)
+ if (commentState.inBlockComment) {
+ context.inBlockComment = true
+ context.type = 'block-comment'
+ return context
+ }
+
+ if (commentState.inLineComment) {
+ context.inLineComment = true
+ context.type = 'line-comment'
+ return context
+ }
+
+ // 分析前缀最后几个字符
+ const sanitizedPrefix = sanitizeStructuralText(prefix)
+ const prefixTrimmed = sanitizedPrefix.trimEnd()
+ const isObjectLiteralStart = () => {
+ if (!/{\s*$/.test(prefixTrimmed)) {
+ return false
+ }
+
+ const beforeBrace = prefixTrimmed.slice(0, prefixTrimmed.lastIndexOf('{')).trimEnd()
+ return !beforeBrace || /[=(:,[]$/.test(beforeBrace) || /\breturn$/.test(beforeBrace)
+ }
+
+ const isObjectPropertyContinuation = () => {
+ if (!/,\s*$/.test(prefixTrimmed)) {
+ return false
+ }
+
+ const beforeComma = prefixTrimmed.slice(0, -1)
+ const openBraces = (beforeComma.match(/{/g) || []).length
+ const closeBraces = (beforeComma.match(/}/g) || []).length
+ return openBraces > closeBraces
+ }
+
+ // 检测是否在对象字面量中
+ if (isObjectLiteralStart() || isObjectPropertyContinuation()) {
+ context.inObject = true
+ context.type = 'object-property'
+ }
+ // 检测是否在表达式中
+ else if (/[=+\-*/%<>!&|([]$/.test(prefixTrimmed) || /,\s*$/.test(prefixTrimmed)) {
+ context.needsExpression = true
+ context.type = 'expression'
+ }
+ // 检测是否在语句开始
+ else if (/[{;]\s*$/.test(prefixTrimmed) || prefixTrimmed.length === 0) {
+ context.needsStatement = true
+ context.type = 'statement'
+ }
+
+ // 检测作用域
+ const openScope = getOpenScopeContext(prefix)
+ context.inFunction = Boolean(openScope.functionName)
+ context.inClass = Boolean(openScope.className)
+
+ return context
+ }
+
+ /**
+ * 优化前缀(限制上下文长度)
+ * @param {string} prefix - 原始前缀
+ * @returns {string} 优化后的前缀
+ */
+ optimizePrefix(prefix) {
+ const MAX_PREFIX_LINES = this.config.FIM.MAX_PREFIX_LINES
+ const lines = prefix.split('\n')
+
+ if (lines.length <= MAX_PREFIX_LINES) {
+ return prefix
+ }
+
+ // 保留最后 N 行
+ return lines.slice(-MAX_PREFIX_LINES).join('\n')
+ }
+
+ /**
+ * 优化后缀(限制上下文长度 + 智能截断)
+ * @param {string} suffix - 原始后缀
+ * @returns {string} 优化后的后缀
+ */
+ optimizeSuffix(suffix) {
+ const MAX_SUFFIX_LINES = this.config.FIM.MAX_SUFFIX_LINES
+ const lines = suffix.split('\n')
+
+ // 智能截断:找到下一个函数/类定义的位置
+ let cutoffIndex = lines.length
+ for (let i = 0; i < Math.min(lines.length, MAX_SUFFIX_LINES); i++) {
+ const line = lines[i].trim()
+
+ // 遇到新的函数/类定义,在此处截断
+ if (
+ line.startsWith('function ') ||
+ line.startsWith('class ') ||
+ (line.startsWith('const ') && line.includes('=>')) ||
+ line.startsWith('export ') ||
+ line.startsWith('import ')
+ ) {
+ cutoffIndex = i
+ break
+ }
+
+ // 遇到闭合的大括号(可能是当前函数/对象的结束)
+ if (line === '}' || line === '};') {
+ cutoffIndex = i + 1 // 包含这个闭合括号
+ break
+ }
+ }
+
+ // 取较小值:要么是智能截断位置,要么是最大行数
+ const finalLines = Math.min(cutoffIndex, MAX_SUFFIX_LINES)
+
+ if (lines.length <= finalLines) {
+ return suffix
+ }
+
+ // 保留前 N 行
+ return lines.slice(0, finalLines).join('\n')
+ }
+}
diff --git a/packages/plugins/script/src/ai-completion/builders/lowcodeContextBuilder.js b/packages/plugins/script/src/ai-completion/builders/lowcodeContextBuilder.js
new file mode 100644
index 0000000000..5fc28720e8
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/builders/lowcodeContextBuilder.js
@@ -0,0 +1,403 @@
+const FACT_LIMITS = {
+ ITEMS: 20,
+ STORE_MEMBERS: 12,
+ SCHEMA_KEYS: 16,
+ HINT_TOKENS: 80
+}
+
+function asArray(value) {
+ return Array.isArray(value) ? value : []
+}
+
+function asRecord(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
+}
+
+function limitList(items, max = FACT_LIMITS.ITEMS) {
+ if (!Array.isArray(items)) {
+ return []
+ }
+
+ return items.filter(Boolean).slice(0, max)
+}
+
+function buildHintContext(hintText = '') {
+ if (typeof hintText !== 'string' || !hintText.trim()) {
+ return {
+ recentTokens: [],
+ tokenSet: new Set(),
+ currentToken: ''
+ }
+ }
+
+ const identifiers = hintText.match(/[A-Za-z_$][\w$]*/g) || []
+ const recentTokens = identifiers.slice(-FACT_LIMITS.HINT_TOKENS).map((token) => token.toLowerCase())
+ const currentTokenMatch = hintText.match(/([A-Za-z_$][\w$]*)$/)
+
+ return {
+ recentTokens,
+ tokenSet: new Set(recentTokens),
+ currentToken: currentTokenMatch?.[1]?.toLowerCase() || ''
+ }
+}
+
+function scoreByHint(item, hintContext, getLookupText) {
+ if (!hintContext.recentTokens.length && !hintContext.currentToken) {
+ return 0
+ }
+
+ const primaryName = String(item?.name || item?.id || '').toLowerCase()
+ const lookupText = String(getLookupText(item) || primaryName).toLowerCase()
+ let score = 0
+
+ if (hintContext.currentToken) {
+ if (primaryName.startsWith(hintContext.currentToken)) {
+ score += 8
+ } else if (lookupText.includes(hintContext.currentToken)) {
+ score += 4
+ }
+ }
+
+ if (primaryName && hintContext.tokenSet.has(primaryName)) {
+ score += 6
+ }
+
+ for (const token of hintContext.recentTokens) {
+ if (!token || token === hintContext.currentToken) {
+ continue
+ }
+
+ if (primaryName && primaryName.startsWith(token)) {
+ score += 3
+ break
+ }
+
+ if (lookupText.includes(token)) {
+ score += 1
+ }
+ }
+
+ return score
+}
+
+function prioritizeItems(items, max = FACT_LIMITS.ITEMS, hintContext, getLookupText = () => '') {
+ const filteredItems = Array.isArray(items) ? items.filter(Boolean) : []
+
+ if (!filteredItems.length) {
+ return {
+ items: [],
+ omitted: 0
+ }
+ }
+
+ const rankedItems = filteredItems.map((item, index) => ({
+ item,
+ index,
+ score: scoreByHint(item, hintContext, getLookupText)
+ }))
+
+ rankedItems.sort((left, right) => right.score - left.score || left.index - right.index)
+
+ const selectedItems = rankedItems
+ .slice(0, max)
+ .sort((left, right) => left.index - right.index)
+ .map((entry) => entry.item)
+
+ return {
+ items: selectedItems,
+ omitted: Math.max(filteredItems.length - selectedItems.length, 0)
+ }
+}
+
+function normalizeDescription(text) {
+ return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
+}
+
+function getValueType(value) {
+ if (Array.isArray(value)) {
+ return 'array'
+ }
+
+ if (value === null) {
+ return 'null'
+ }
+
+ return typeof value
+}
+
+/**
+ * 格式化数据信息
+ * @param {Array} dataSource - 数据源数组
+ * @returns {Array} 格式化后的数据源
+ */
+function formatDataSources(dataSource, hintContext) {
+ const candidates = asArray(dataSource)
+ .filter((ds) => ds?.name)
+ .map((ds) => ({
+ name: ds.name,
+ type: ds.type || 'unknown',
+ accessPath: `this.dataSourceMap.${ds.name}.load()`,
+ description: normalizeDescription(ds.description || `Data source: ${ds.name}`)
+ }))
+
+ return prioritizeItems(candidates, FACT_LIMITS.ITEMS, hintContext, (item) =>
+ [item.name, item.accessPath, item.description].filter(Boolean).join(' ')
+ )
+}
+
+/**
+ * 从函数代码中提取参数列表
+ * @param {string} functionCode - 函数代码字符串
+ * @returns {string} 参数列表
+ */
+function extractFunctionParams(functionCode) {
+ if (!functionCode) return ''
+
+ const funcMatch = functionCode.match(/function(?:\s+\w+)?\s*\(([^)]*)\)/)
+ if (funcMatch) {
+ return funcMatch[1].trim()
+ }
+
+ const arrowMatch = functionCode.match(/(?:\(([^)]*)\)|(\w+))\s*=>/)
+ if (arrowMatch) {
+ return (arrowMatch[1] || arrowMatch[2] || '').trim()
+ }
+
+ return ''
+}
+
+function createCallableAccess(prefix, name, functionCode) {
+ const params = extractFunctionParams(functionCode)
+ return `${prefix}${name}(${params})`
+}
+
+/**
+ * 格式化工具类信息
+ * @param {Array} utils - 工具类数组
+ * @returns {Array} 格式化后的工具类
+ */
+function formatUtils(utils, hintContext) {
+ const candidates = asArray(utils)
+ .filter((util) => util?.name)
+ .map((util) => {
+ const formatted = {
+ name: util.name,
+ type: util.type || 'function',
+ accessPath: `this.utils.${util.name}`
+ }
+
+ if (util.type === 'npm' && util.content) {
+ formatted.package = util.content.package
+ formatted.description = `npm utility from ${util.content.package}`
+ }
+
+ if (util.type === 'function' && util.content?.type === 'JSFunction') {
+ formatted.signature = createCallableAccess('this.utils.', util.name, util.content.value)
+ formatted.description = `Utility function: ${util.name}`
+ }
+
+ return formatted
+ })
+
+ return prioritizeItems(candidates, FACT_LIMITS.ITEMS, hintContext, (item) =>
+ [item.name, item.signature, item.accessPath, item.package, item.description].filter(Boolean).join(' ')
+ )
+}
+
+/**
+ * 格式化 bridge 信息
+ * @param {Array} bridge - bridge 数组
+ * @returns {Array} 格式化后的 bridge 信息
+ */
+function formatBridge(bridge, hintContext) {
+ const candidates = asArray(bridge)
+ .filter((item) => item?.name)
+ .map((item) => ({
+ name: item.name,
+ accessPath: `this.bridge.${item.name}`,
+ description: normalizeDescription(item.description || `Bridge API: ${item.name}`)
+ }))
+
+ return prioritizeItems(candidates, FACT_LIMITS.ITEMS, hintContext, (item) =>
+ [item.name, item.accessPath, item.description].filter(Boolean).join(' ')
+ )
+}
+
+/**
+ * 格式化全局状态信息
+ * @param {Array} globalState - 全局状态数组
+ * @returns {Array} 格式化后的全局状态
+ */
+function formatGlobalState(globalState, hintContext) {
+ const candidates = asArray(globalState)
+ .filter((store) => store?.id)
+ .map((store) => ({
+ id: store.id,
+ state: limitList(Object.keys(store.state || {}), FACT_LIMITS.STORE_MEMBERS),
+ getters: limitList(Object.keys(store.getters || {}), FACT_LIMITS.STORE_MEMBERS),
+ actions: limitList(Object.keys(store.actions || {}), FACT_LIMITS.STORE_MEMBERS),
+ description: `Pinia store: ${store.id}`
+ }))
+
+ return prioritizeItems(candidates, FACT_LIMITS.ITEMS, hintContext, (item) =>
+ [item.id, ...item.state, ...item.getters, ...item.actions].filter(Boolean).join(' ')
+ )
+}
+
+/**
+ * 格式化本地状态
+ * @param {Object} state - 状态对象
+ * @returns {Array} 格式化后的状态
+ */
+function formatState(state, hintContext) {
+ const candidates = Object.entries(asRecord(state)).map(([key, value]) => ({
+ name: key,
+ accessPath: `this.state.${key}`,
+ type: getValueType(value)
+ }))
+
+ return prioritizeItems(candidates, FACT_LIMITS.ITEMS, hintContext, (item) =>
+ [item.name, item.accessPath, item.type].filter(Boolean).join(' ')
+ )
+}
+
+/**
+ * 格式化本地方法
+ * @param {Object} methods - 方法对象
+ * @returns {Array} 格式化后的方法
+ */
+function formatMethods(methods, hintContext) {
+ const candidates = Object.entries(asRecord(methods)).map(([key, value]) => ({
+ name: key,
+ accessPath: `this.${key}`,
+ signature: value?.type === 'JSFunction' ? createCallableAccess('this.', key, value.value) : `this.${key}()`,
+ description: `Method: ${key}`
+ }))
+
+ return prioritizeItems(candidates, FACT_LIMITS.ITEMS, hintContext, (item) =>
+ [item.name, item.signature, item.accessPath, item.description].filter(Boolean).join(' ')
+ )
+}
+
+function prioritizeSchemaKeys(keys, max, hintContext) {
+ const candidates = keys.map((name) => ({ name }))
+ const { items, omitted } = prioritizeItems(candidates, max, hintContext, (item) => item.name)
+
+ return {
+ items: items.map((item) => item.name),
+ omitted
+ }
+}
+
+/**
+ * 格式化当前组件 schema
+ * @param {Object} schema - 组件 schema
+ * @returns {Object|null} 格式化后的 schema
+ */
+function formatCurrentSchema(schema, hintContext) {
+ const normalizedSchema = schema && typeof schema === 'object' && !Array.isArray(schema) ? schema : null
+
+ if (!normalizedSchema) {
+ return {
+ schema: null,
+ truncated: {
+ props: 0,
+ events: 0,
+ dynamicProps: 0
+ }
+ }
+ }
+
+ const formatted = {
+ componentName: normalizedSchema.componentName || 'Unknown',
+ ...(normalizedSchema.ref && { ref: normalizedSchema.ref, refAccess: `this.$('${normalizedSchema.ref}')` })
+ }
+
+ const truncated = {
+ props: 0,
+ events: 0,
+ dynamicProps: 0
+ }
+
+ const schemaProps = asRecord(normalizedSchema.props)
+
+ if (Object.keys(schemaProps).length > 0) {
+ const propKeys = []
+ const eventKeys = []
+ const dynamicPropKeys = []
+
+ for (const [key, value] of Object.entries(schemaProps)) {
+ if (key.startsWith('on')) {
+ eventKeys.push(key)
+ } else {
+ propKeys.push(key)
+ }
+
+ if (value && (value.type === 'JSExpression' || value.type === 'JSFunction')) {
+ dynamicPropKeys.push(key)
+ }
+ }
+
+ const prioritizedProps = prioritizeSchemaKeys(propKeys, FACT_LIMITS.SCHEMA_KEYS, hintContext)
+ const prioritizedEvents = prioritizeSchemaKeys(eventKeys, FACT_LIMITS.SCHEMA_KEYS, hintContext)
+ const prioritizedDynamicProps = prioritizeSchemaKeys(dynamicPropKeys, FACT_LIMITS.SCHEMA_KEYS, hintContext)
+
+ formatted.props = prioritizedProps.items
+ formatted.events = prioritizedEvents.items
+ formatted.dynamicProps = prioritizedDynamicProps.items
+
+ truncated.props = prioritizedProps.omitted
+ truncated.events = prioritizedEvents.omitted
+ truncated.dynamicProps = prioritizedDynamicProps.omitted
+ }
+
+ return {
+ schema: formatted,
+ truncated
+ }
+}
+
+/**
+ * 从低代码平台元数据构建补全上下文
+ * @param {Object} metadata - 低代码平台元数据
+ * @returns {Object} 格式化的低代码上下文
+ */
+export function buildLowcodeContext(metadata = {}, options = {}) {
+ const normalizedMetadata = metadata && typeof metadata === 'object' ? metadata : {}
+ const {
+ dataSource = [],
+ utils = [],
+ bridge = [],
+ globalState = [],
+ state = {},
+ methods = {},
+ currentSchema = null
+ } = normalizedMetadata
+ const hintContext = buildHintContext(options.hintText)
+ const formattedDataSource = formatDataSources(dataSource, hintContext)
+ const formattedUtils = formatUtils(utils, hintContext)
+ const formattedBridge = formatBridge(bridge, hintContext)
+ const formattedGlobalState = formatGlobalState(globalState, hintContext)
+ const formattedState = formatState(state, hintContext)
+ const formattedMethods = formatMethods(methods, hintContext)
+ const formattedSchema = formatCurrentSchema(currentSchema, hintContext)
+
+ return {
+ dataSource: formattedDataSource.items,
+ utils: formattedUtils.items,
+ bridge: formattedBridge.items,
+ globalState: formattedGlobalState.items,
+ state: formattedState.items,
+ methods: formattedMethods.items,
+ currentSchema: formattedSchema.schema,
+ truncated: {
+ dataSource: formattedDataSource.omitted,
+ utils: formattedUtils.omitted,
+ bridge: formattedBridge.omitted,
+ globalState: formattedGlobalState.omitted,
+ state: formattedState.omitted,
+ methods: formattedMethods.omitted,
+ currentSchema: formattedSchema.truncated
+ }
+ }
+}
diff --git a/packages/plugins/script/src/ai-completion/builders/promptBuilder.js b/packages/plugins/script/src/ai-completion/builders/promptBuilder.js
new file mode 100644
index 0000000000..4ff06ce0d0
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/builders/promptBuilder.js
@@ -0,0 +1,85 @@
+import { buildLowcodeContext } from './lowcodeContextBuilder.js'
+import { getCommentState, getOpenScopeContext } from '../utils/contextAnalysis.js'
+
+/**
+ * 检测光标是否在注释中
+ * @param {string} textBeforeCursor - 光标前的文本
+ * @returns {{ isComment: boolean, type: string | null }} 注释状态
+ */
+function isInComment(textBeforeCursor) {
+ const commentState = getCommentState(textBeforeCursor)
+
+ if (commentState.inLineComment) {
+ return { isComment: true, type: 'line' }
+ }
+
+ if (commentState.inBlockComment) {
+ return { isComment: true, type: 'block' }
+ }
+
+ return { isComment: false, type: null }
+}
+
+/**
+ * 提取当前代码上下文信息(函数名、类名)
+ * @param {string} textBeforeCursor - 光标前的文本
+ * @returns {{ functionName: string, className: string }} 代码上下文
+ */
+function extractCodeContext(textBeforeCursor) {
+ const openScope = getOpenScopeContext(textBeforeCursor)
+
+ return {
+ functionName: openScope.functionName,
+ className: openScope.className
+ }
+}
+
+/**
+ * 构建元信息注释
+ * @param {Object} codeContext - 代码上下文
+ * @returns {string} 元信息字符串
+ */
+function buildMetaInfo(codeContext) {
+ const metaLines = []
+
+ if (codeContext.className) {
+ metaLines.push(`// Current Class: ${codeContext.className}`)
+ }
+
+ if (codeContext.functionName) {
+ metaLines.push(`// Current Function: ${codeContext.functionName}`)
+ }
+
+ return metaLines.length ? `${metaLines.join('\n')}\n\n` : ''
+}
+
+/**
+ * 创建智能 Prompt,根据上下文优化补全
+ * @param {Object} completionMetadata - 补全元数据
+ * @returns {{ fileContent: string, commentStatus: object, lowcodeContext: object | null }} Prompt 对象
+ */
+export function createSmartPrompt(completionMetadata) {
+ const { textBeforeCursor = '', textAfterCursor = '', lowcodeMetadata = null } = completionMetadata
+
+ const commentStatus = isInComment(textBeforeCursor)
+ const codeContext = extractCodeContext(textBeforeCursor)
+ let lowcodeContext = null
+
+ // 用极少量上下文注释提醒当前开放作用域,避免重复注入过多控制信息
+ const metaInfo = buildMetaInfo(codeContext)
+
+ if (lowcodeMetadata) {
+ lowcodeContext = buildLowcodeContext(lowcodeMetadata, {
+ hintText: textBeforeCursor
+ })
+ }
+
+ // 在文件内容前注入元信息
+ const fileContent = `${metaInfo}${textBeforeCursor}[CURSOR]${textAfterCursor}`
+
+ return {
+ fileContent,
+ commentStatus,
+ lowcodeContext
+ }
+}
diff --git a/packages/plugins/script/src/ai-completion/constants.js b/packages/plugins/script/src/ai-completion/constants.js
new file mode 100644
index 0000000000..62639bfcef
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/constants.js
@@ -0,0 +1,155 @@
+/**
+ * Qwen Coder API 配置(阿里云百炼)
+ */
+export const QWEN_CONFIG = {
+ COMPLETION_PATH: '/completions', // Completions API 路径(追加到 baseUrl)
+ DEFAULT_TEMPERATURE: 0.05,
+ TOP_P: 0.95,
+ PRESENCE_PENALTY: 0.2,
+
+ // FIM (Fill-In-the-Middle) 优化配置
+ FIM: {
+ MAX_PREFIX_LINES: 100,
+ MAX_SUFFIX_LINES: 50
+ }
+}
+
+/**
+ * DeepSeek Coder API 配置
+ */
+export const DEEPSEEK_CONFIG = {
+ COMPLETION_PATH: '/beta', // FIM 补全 API 路径
+ PATH_REPLACE: '/v1', // 需要从 baseUrl 中替换的路径
+ DEFAULT_TEMPERATURE: 0,
+ TOP_P: 1.0,
+
+ // FIM (Fill-In-the-Middle) 配置
+ FIM: {
+ MAX_PREFIX_LINES: 100,
+ MAX_SUFFIX_LINES: 50,
+ MAX_TOKENS: 4096 // FIM 最大补全长度 4K
+ }
+}
+
+/**
+ * 模型配置
+ */
+export const MODEL_CONFIG = {
+ QWEN: {
+ TYPE: 'qwen',
+ COMPLETION_MODELS: ['qwen-coder-turbo-latest', 'qwen-coder-turbo-0919', 'qwen-coder-turbo'],
+ COMPLETION_MODEL_PATTERNS: [/^qwen2\.5-coder-(7|14|32)b-instruct$/]
+ },
+ DEEPSEEK: {
+ TYPE: 'deepseek',
+ COMPLETION_MODELS: ['deepseek-chat', 'deepseek-coder'],
+ COMPLETION_MODEL_PATTERNS: []
+ },
+ UNKNOWN: {
+ TYPE: 'unknown'
+ }
+}
+
+/**
+ * HTTP 请求配置
+ */
+export const HTTP_CONFIG = {
+ METHOD: 'POST',
+ CONTENT_TYPE: 'application/json',
+ STREAM: false,
+ REQUEST_TIMEOUT_MS: 15000
+}
+
+/**
+ * 默认配置
+ */
+export const DEFAULTS = {
+ LANGUAGE: 'javascript'
+}
+
+/**
+ * 错误消息配置
+ */
+export const ERROR_MESSAGES = {
+ CONFIG_MISSING: 'AI 配置未设置(缺少 model/apiKey/baseUrl)',
+ API_KEY_MISSING: 'AI 配置未设置(缺少 API Key)',
+ UNSUPPORTED_MODEL: '当前代码补全模型未配置可用的补全协议,请在模型设置中指定协议或选择内置代码模型',
+ NO_COMPLETION: '未收到有效的补全结果',
+ REQUEST_FAILED: '请求失败',
+ QWEN_API_ERROR: 'Qwen API 错误'
+}
+
+/**
+ * 通用模型配置
+ */
+export const MODEL_COMMON_CONFIG = {
+ // Token 限制
+ TOKEN_LIMITS: {
+ EXPRESSION: 64,
+ STATEMENT: 256,
+ FUNCTION: 200,
+ CLASS: 256,
+ DEFAULT: 128
+ },
+
+ // 清理规则
+ CLEANUP_PATTERNS: {
+ MARKDOWN_CODE_BLOCK: /^```[\w]*\n?|```$/g,
+ TRAILING_SEMICOLON: /;\s*$/,
+ LEADING_EMPTY_LINES: /^\n+/,
+ TRAILING_EMPTY_LINES: /\n+$/
+ },
+
+ // 智能截断配置
+ TRUNCATION: {
+ MAX_LINES: {
+ EXPRESSION: 1,
+ OBJECT: 5,
+ DEFAULT: 10
+ },
+ CUTOFF_KEYWORDS: ['function ', 'class ', 'export ', 'import '],
+ BLOCK_ENDINGS: ['}', '};']
+ }
+}
+
+// 停止符配置(API 限制:最多 16 个)
+export const STOP_SEQUENCES = {
+ CORE: ['\n\n', '```'],
+ NEW_SCOPE: ['\nfunction ', '\nclass ', '\nexport ', '\nimport '],
+ BLOCK_END: ['\n}', '\n};']
+}
+
+// 上下文特定停止符
+export const CONTEXT_STOP_SEQUENCES = {
+ EXPRESSION: [';', ',', '\n)'],
+ COMMENT: ['\n\n', '*/'],
+ OBJECT: ['\n}', '\n};'],
+ FUNCTION: ['\n}', '\nfunction ', '\nreturn ']
+}
+
+// FIM 标记配置
+export const FIM_CONFIG = {
+ MARKERS: {
+ PREFIX: '<|fim_prefix|>',
+ SUFFIX: '<|fim_suffix|>',
+ MIDDLE: '<|fim_middle|>',
+ CURSOR: '[CURSOR]'
+ }
+}
+
+/**
+ * 代码上下文分析配置
+ */
+export const CONTEXT_CONFIG = {
+ MAX_LINES_TO_SCAN: 20
+}
+
+/**
+ * 代码模式匹配(JS/TS)
+ */
+export const CODE_PATTERNS = {
+ // 匹配接口定义(TS)
+ INTERFACE: /interface\s+(\w+)/,
+ // 匹配类型定义(TS)
+ TYPE: /type\s+(\w+)/
+}
diff --git a/packages/plugins/script/src/ai-completion/prompts/templates.js b/packages/plugins/script/src/ai-completion/prompts/templates.js
new file mode 100644
index 0000000000..3c48a9a0a6
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/prompts/templates.js
@@ -0,0 +1,173 @@
+function createSection(title, lines = []) {
+ const validLines = lines.filter(Boolean)
+
+ if (!validLines.length) {
+ return ''
+ }
+
+ return `${title}:\n${validLines.map((line) => `- ${line}`).join('\n')}`
+}
+
+function formatFactWithDescription(accessPath, description = '', extra = '') {
+ const details = [extra, description].filter(Boolean).join(' | ')
+ return details ? `${accessPath} // ${details}` : accessPath
+}
+
+function formatStoreFacts(globalState) {
+ return globalState.map((store) => {
+ const members = [...store.state, ...store.getters].map((name) => `this.stores.${store.id}.${name}`)
+ const actions = store.actions.map((name) => `this.stores.${store.id}.${name}()`)
+ const facts = [...members, ...actions]
+ return facts.length ? facts.join(', ') : `this.stores.${store.id}`
+ })
+}
+
+function formatSchemaFacts(currentSchema) {
+ if (!currentSchema) {
+ return []
+ }
+
+ return [
+ `component: ${currentSchema.componentName || 'Unknown'}`,
+ currentSchema.refAccess ? `ref: ${currentSchema.refAccess}` : '',
+ currentSchema.props?.length ? `props: ${currentSchema.props.join(', ')}` : '',
+ currentSchema.events?.length ? `events: ${currentSchema.events.join(', ')}` : '',
+ currentSchema.dynamicProps?.length ? `dynamic props: ${currentSchema.dynamicProps.join(', ')}` : ''
+ ]
+}
+
+function appendOverflow(lines, omitted, label) {
+ if (!omitted) {
+ return lines
+ }
+
+ return [...lines, `...and ${omitted} more ${label} not shown`]
+}
+
+function buildLowcodeFacts(lowcodeContext = {}) {
+ const {
+ dataSource = [],
+ utils = [],
+ bridge = [],
+ globalState = [],
+ state = [],
+ methods = [],
+ currentSchema = null,
+ truncated = {}
+ } = lowcodeContext
+ const schemaTruncated = truncated.currentSchema || {}
+
+ const sections = [
+ createSection('Platform APIs', [
+ 'this.props, this.emit, this.setState, this.route, this.history',
+ 'this.i18n, this.getLocale(), this.setLocale()',
+ "this.$('refName') for component refs",
+ 'this.dataSourceMap..load() for data loading'
+ ]),
+ createSection(
+ 'Data sources',
+ appendOverflow(
+ dataSource.map((item) => formatFactWithDescription(item.accessPath, item.description, item.type)),
+ truncated.dataSource,
+ 'data sources'
+ )
+ ),
+ createSection(
+ 'Utilities',
+ appendOverflow(
+ utils.map((item) =>
+ formatFactWithDescription(item.signature || item.accessPath, item.description, item.package)
+ ),
+ truncated.utils,
+ 'utilities'
+ )
+ ),
+ createSection(
+ 'Bridge APIs',
+ appendOverflow(
+ bridge.map((item) => formatFactWithDescription(item.accessPath, item.description)),
+ truncated.bridge,
+ 'bridge APIs'
+ )
+ ),
+ createSection(
+ 'Global stores',
+ appendOverflow(formatStoreFacts(globalState), truncated.globalState, 'global stores')
+ ),
+ createSection(
+ 'Local state',
+ appendOverflow(
+ state.map((item) => `${item.accessPath}: ${item.type}`),
+ truncated.state,
+ 'local state fields'
+ )
+ ),
+ createSection(
+ 'Local methods',
+ appendOverflow(
+ methods.map((item) => item.signature || `${item.accessPath}()`),
+ truncated.methods,
+ 'local methods'
+ )
+ ),
+ createSection(
+ 'Current component',
+ appendOverflow(
+ appendOverflow(
+ appendOverflow(formatSchemaFacts(currentSchema), schemaTruncated.props, 'component props'),
+ schemaTruncated.events,
+ 'component events'
+ ),
+ schemaTruncated.dynamicProps,
+ 'dynamic component props'
+ )
+ )
+ ].filter(Boolean)
+
+ return sections.join('\n\n')
+}
+
+/**
+ * 代码补全指令模板
+ * @param {string} language - 编程语言
+ * @returns {string} 指令文本
+ */
+export function createCodeInstruction(language) {
+ return `Complete the ${language} code at the cursor.
+Return only the inserted code.
+Keep the completion minimal and stay in the current scope.`
+}
+
+/**
+ * 块注释补全指令(JSDoc)
+ */
+export const BLOCK_COMMENT_INSTRUCTION = `Complete the current JSDoc comment only.
+Keep it concise, accurate, and aligned with the nearby code.
+Do not generate code.`
+
+/**
+ * 行注释补全指令
+ */
+export const LINE_COMMENT_INSTRUCTION = `Complete the current inline comment only.
+Keep it brief and explain intent, not implementation details.
+Do not generate code.`
+
+/**
+ * 低代码平台上下文增强 Prompt
+ */
+export const LOWCODE_CONTEXT_INSTRUCTION = `You are completing code inside a TinyEngine low-code page script.
+Prefer the project symbols and runtime APIs listed below.
+Use data sources via this.dataSourceMap..load().`
+
+/**
+ * 创建带低代码上下文的指令
+ * @param {string} language - 编程语言
+ * @param {Object} lowcodeContext - 低代码上下文数据
+ * @returns {string} 增强的指令文本
+ */
+export function createLowcodeInstruction(language, lowcodeContext = {}) {
+ const instruction = [createCodeInstruction(language), LOWCODE_CONTEXT_INSTRUCTION].join('\n')
+ const facts = buildLowcodeFacts(lowcodeContext)
+
+ return facts ? `${instruction}\n\n${facts}` : instruction
+}
diff --git a/packages/plugins/script/src/ai-completion/triggers/completionTrigger.js b/packages/plugins/script/src/ai-completion/triggers/completionTrigger.js
new file mode 100644
index 0000000000..d316c497b6
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/triggers/completionTrigger.js
@@ -0,0 +1,86 @@
+import { getCommentState, sanitizeStructuralText } from '../utils/contextAnalysis.js'
+
+/**
+ * 检测光标是否在语句结束符后(分号后)
+ */
+function isAfterStatementEnd(beforeCursor, textBeforeCursor) {
+ // 检查是否以分号结尾(忽略尾部空格)
+ const trimmedEnd = beforeCursor.trimEnd()
+
+ if (trimmedEnd.endsWith(';')) {
+ const structuralTextBeforeCursor = sanitizeStructuralText(textBeforeCursor)
+
+ // 排除 for 循环中的分号:for (let i = 0; i < 10; i++)
+ // 检查是否在括号内
+ const openParens = (structuralTextBeforeCursor.match(/\(/g) || []).length
+ const closeParens = (structuralTextBeforeCursor.match(/\)/g) || []).length
+
+ // 如果括号未闭合,说明可能在 for 循环中
+ if (openParens > closeParens) {
+ return false
+ }
+
+ return true
+ }
+
+ return false
+}
+
+/**
+ * 检测光标是否在代码块结束符后(右花括号后)
+ */
+function isAfterBlockEnd(beforeCursor) {
+ const trimmedEnd = beforeCursor.trimEnd()
+
+ // 检查是否以右花括号结尾
+ if (trimmedEnd.endsWith('}')) {
+ // 检查后面是否只有空格(没有其他字符)
+ const afterBrace = beforeCursor.substring(trimmedEnd.length)
+ return afterBrace.trim().length === 0
+ }
+
+ return false
+}
+
+/**
+ * 判断是否应该触发代码补全
+ * @param {Object} params - 触发参数
+ * @param {string} params.text - 完整文本
+ * @param {Object} params.position - 光标位置
+ * @param {number} params.position.lineNumber - 行号
+ * @param {number} params.position.column - 列号
+ * @returns {boolean} 是否触发补全
+ */
+export function shouldTriggerCompletion(params) {
+ const { text, position } = params
+ const lines = text.split('\n')
+ const currentLine = lines[position.lineNumber - 1] || ''
+ const beforeCursor = currentLine.substring(0, position.column - 1)
+ const textBeforeCursor = `${lines.slice(0, position.lineNumber - 1).join('\n')}${
+ position.lineNumber > 1 ? '\n' : ''
+ }${beforeCursor}`
+ const lexicalState = getCommentState(textBeforeCursor)
+
+ // 1. 代码太短不触发
+ if (text.trim().length < 2) {
+ return false
+ }
+
+ // 2. 注释和字符串里不触发
+ if (lexicalState.inComment || lexicalState.inString) {
+ return false
+ }
+
+ // 3. 分号后不触发(语句已结束)
+ if (isAfterStatementEnd(beforeCursor, textBeforeCursor)) {
+ return false
+ }
+
+ // 4. 右花括号后不触发(块已结束)
+ if (isAfterBlockEnd(beforeCursor)) {
+ return false
+ }
+
+ // 其他情况都允许触发
+ return true
+}
diff --git a/packages/plugins/script/src/ai-completion/utils/completionUtils.js b/packages/plugins/script/src/ai-completion/utils/completionUtils.js
new file mode 100644
index 0000000000..776dff4fe7
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/utils/completionUtils.js
@@ -0,0 +1,110 @@
+import { useResource, useCanvas } from '@opentiny/tiny-engine-meta-register'
+import { MODEL_COMMON_CONFIG } from '../constants.js'
+
+function trimSuffixOverlap(text, suffix = '') {
+ if (!text || !suffix) {
+ return text
+ }
+
+ const candidates = [suffix, suffix.trimStart()].filter(Boolean)
+ let bestOverlap = 0
+
+ for (const candidate of candidates) {
+ const maxOverlap = Math.min(text.length, candidate.length)
+
+ for (let size = maxOverlap; size > bestOverlap; size--) {
+ if (text.endsWith(candidate.slice(0, size))) {
+ bestOverlap = size
+ break
+ }
+ }
+ }
+
+ return bestOverlap > 0 ? text.slice(0, -bestOverlap) : text
+}
+
+/**
+ * 构建低代码元数据
+ * @returns {Object} 低代码元数据
+ */
+export function buildLowcodeMetadata() {
+ const { dataSource = [], utils = [], bridge = [], globalState = [] } = useResource().appSchemaState || {}
+ const { state: pageState = {}, methods = {} } = useCanvas().getPageSchema() || {}
+ const currentSchema = useCanvas().getCurrentSchema()
+
+ return {
+ dataSource,
+ utils,
+ bridge,
+ globalState,
+ state: pageState,
+ methods,
+ currentSchema
+ }
+}
+
+/**
+ * 清理补全文本
+ * @param {string} text - 原始补全文本
+ * @param {Object} cursorContext - 光标上下文信息(可选)
+ * @param {string} suffix - 光标后的原始文本
+ * @returns {string} 清理后的文本
+ */
+export function cleanCompletion(text, cursorContext = null, suffix = '') {
+ if (!text) return text
+
+ let cleaned = text
+
+ // 1. 移除 markdown 代码块
+ cleaned = cleaned.replace(MODEL_COMMON_CONFIG.CLEANUP_PATTERNS.MARKDOWN_CODE_BLOCK, '')
+
+ // 2. 移除 [CURSOR] 标记(如果模型返回了它)
+ cleaned = cleaned.replace(/\[CURSOR\]/g, '')
+ cleaned = cleaned.replace(/\/\/ \[CURSOR\]/g, '')
+
+ // 3. 移除前后空行
+ cleaned = cleaned.replace(MODEL_COMMON_CONFIG.CLEANUP_PATTERNS.LEADING_EMPTY_LINES, '')
+ cleaned = cleaned.replace(MODEL_COMMON_CONFIG.CLEANUP_PATTERNS.TRAILING_EMPTY_LINES, '')
+
+ // 4. 表达式特殊处理:移除尾部分号
+ if (cursorContext?.needsExpression) {
+ cleaned = cleaned.replace(MODEL_COMMON_CONFIG.CLEANUP_PATTERNS.TRAILING_SEMICOLON, '')
+ }
+
+ // 5. 智能截断:防止返回过多不相关代码
+ const lines = cleaned.split('\n')
+
+ // 根据上下文确定最大行数
+ const truncation = MODEL_COMMON_CONFIG.TRUNCATION
+ const maxLines = cursorContext?.needsExpression
+ ? truncation.MAX_LINES.EXPRESSION
+ : cursorContext?.inObject
+ ? truncation.MAX_LINES.OBJECT
+ : truncation.MAX_LINES.DEFAULT
+
+ if (lines.length > maxLines) {
+ // 找到合适的截断点
+ let cutoffIndex = maxLines
+ for (let i = 0; i < maxLines && i < lines.length; i++) {
+ const line = lines[i].trim()
+
+ // 在函数/类定义处截断
+ if (truncation.CUTOFF_KEYWORDS.some((keyword) => line.startsWith(keyword))) {
+ cutoffIndex = i
+ break
+ }
+
+ // 在闭合大括号处截断(完整的代码块)
+ if (truncation.BLOCK_ENDINGS.includes(line)) {
+ cutoffIndex = i + 1
+ break
+ }
+ }
+
+ cleaned = lines.slice(0, cutoffIndex).join('\n')
+ }
+
+ cleaned = trimSuffixOverlap(cleaned, suffix)
+
+ return cleaned.trim()
+}
diff --git a/packages/plugins/script/src/ai-completion/utils/contextAnalysis.js b/packages/plugins/script/src/ai-completion/utils/contextAnalysis.js
new file mode 100644
index 0000000000..3e576cc25e
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/utils/contextAnalysis.js
@@ -0,0 +1,519 @@
+const CONTROL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'with'])
+const REGEX_PREFIX_KEYWORDS = new Set([
+ 'await',
+ 'case',
+ 'delete',
+ 'do',
+ 'else',
+ 'in',
+ 'instanceof',
+ 'new',
+ 'of',
+ 'return',
+ 'throw',
+ 'typeof',
+ 'void',
+ 'yield'
+])
+const REGEX_PREFIX_CHARS = new Set([
+ '(',
+ '[',
+ '{',
+ '=',
+ ':',
+ ',',
+ ';',
+ '!',
+ '?',
+ '~',
+ '+',
+ '-',
+ '*',
+ '%',
+ '^',
+ '&',
+ '|',
+ '<',
+ '>'
+])
+
+function maskChar(char) {
+ return char === '\n' ? '\n' : ' '
+}
+
+function isIdentifierStart(char = '') {
+ return /[A-Za-z_$]/.test(char)
+}
+
+function isIdentifierPart(char = '') {
+ return /[\w$]/.test(char)
+}
+
+function readIdentifier(text = '', start = 0) {
+ let end = start + 1
+
+ while (end < text.length && isIdentifierPart(text[end])) {
+ end++
+ }
+
+ return {
+ value: text.slice(start, end),
+ end
+ }
+}
+
+function isRegexStart(lastToken, nextChar = '') {
+ if (!nextChar || nextChar === '/' || nextChar === '*') {
+ return false
+ }
+
+ if (!lastToken) {
+ return true
+ }
+
+ if (lastToken.type === 'word') {
+ return REGEX_PREFIX_KEYWORDS.has(lastToken.value)
+ }
+
+ return lastToken.type === 'char' ? REGEX_PREFIX_CHARS.has(lastToken.value) : false
+}
+
+function createLiteralToken() {
+ return {
+ type: 'literal',
+ value: 'literal'
+ }
+}
+
+export function getCommentState(text = '') {
+ let inSingleQuote = false
+ let inDoubleQuote = false
+ let inTemplate = false
+ let templateExpressionDepth = 0
+ let inBlockComment = false
+ let inLineComment = false
+ let inRegex = false
+ let inRegexCharClass = false
+ let lastToken = null
+
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i]
+ const next = text[i + 1]
+
+ if (inLineComment) {
+ if (char === '\n') {
+ inLineComment = false
+ }
+ continue
+ }
+
+ if (inBlockComment) {
+ if (char === '*' && next === '/') {
+ inBlockComment = false
+ i++
+ }
+ continue
+ }
+
+ if (inRegex) {
+ if (char === '\\') {
+ i++
+ continue
+ }
+
+ if (inRegexCharClass) {
+ if (char === ']') {
+ inRegexCharClass = false
+ }
+ continue
+ }
+
+ if (char === '[') {
+ inRegexCharClass = true
+ continue
+ }
+
+ if (char === '/') {
+ inRegex = false
+ lastToken = createLiteralToken()
+ }
+ continue
+ }
+
+ if (inSingleQuote) {
+ if (char === '\\') {
+ i++
+ } else if (char === "'") {
+ inSingleQuote = false
+ lastToken = createLiteralToken()
+ }
+ continue
+ }
+
+ if (inDoubleQuote) {
+ if (char === '\\') {
+ i++
+ } else if (char === '"') {
+ inDoubleQuote = false
+ lastToken = createLiteralToken()
+ }
+ continue
+ }
+
+ if (inTemplate && templateExpressionDepth === 0) {
+ if (char === '\\') {
+ i++
+ } else if (char === '`') {
+ inTemplate = false
+ lastToken = createLiteralToken()
+ } else if (char === '$' && next === '{') {
+ templateExpressionDepth = 1
+ i++
+ lastToken = null
+ }
+ continue
+ }
+
+ if (char === '/' && next === '/') {
+ inLineComment = true
+ i++
+ continue
+ }
+
+ if (char === '/' && next === '*') {
+ inBlockComment = true
+ i++
+ continue
+ }
+
+ if (char === '/' && isRegexStart(lastToken, next)) {
+ inRegex = true
+ inRegexCharClass = false
+ continue
+ }
+
+ if (char === "'") {
+ inSingleQuote = true
+ continue
+ }
+
+ if (char === '"') {
+ inDoubleQuote = true
+ continue
+ }
+
+ if (char === '`') {
+ inTemplate = true
+ continue
+ }
+
+ if (inTemplate && templateExpressionDepth > 0) {
+ if (char === '{') {
+ templateExpressionDepth++
+ lastToken = {
+ type: 'char',
+ value: '{'
+ }
+ } else if (char === '}') {
+ templateExpressionDepth--
+ lastToken =
+ templateExpressionDepth === 0
+ ? createLiteralToken()
+ : {
+ type: 'char',
+ value: '}'
+ }
+ }
+ continue
+ }
+
+ if (isIdentifierStart(char)) {
+ const identifier = readIdentifier(text, i)
+ lastToken = {
+ type: 'word',
+ value: identifier.value
+ }
+ i = identifier.end - 1
+ continue
+ }
+
+ if (!/\s/.test(char)) {
+ lastToken = {
+ type: 'char',
+ value: char
+ }
+ }
+ }
+
+ const inTemplateString = inTemplate && templateExpressionDepth === 0
+
+ return {
+ inBlockComment,
+ inLineComment,
+ inComment: inBlockComment || inLineComment,
+ inString: inSingleQuote || inDoubleQuote || inTemplateString,
+ inTemplateString
+ }
+}
+
+export function sanitizeStructuralText(text = '') {
+ const sanitized = []
+ let inSingleQuote = false
+ let inDoubleQuote = false
+ let inTemplate = false
+ let templateExpressionDepth = 0
+ let inBlockComment = false
+ let inLineComment = false
+ let inRegex = false
+ let inRegexCharClass = false
+ let lastToken = null
+
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i]
+ const next = text[i + 1]
+
+ if (inLineComment) {
+ sanitized.push(maskChar(char))
+ if (char === '\n') {
+ inLineComment = false
+ }
+ continue
+ }
+
+ if (inBlockComment) {
+ sanitized.push(maskChar(char))
+ if (char === '*' && next === '/') {
+ sanitized.push(maskChar(next))
+ inBlockComment = false
+ i++
+ }
+ continue
+ }
+
+ if (inRegex) {
+ sanitized.push(maskChar(char))
+ if (char === '\\') {
+ sanitized.push(maskChar(next))
+ i++
+ continue
+ }
+
+ if (inRegexCharClass) {
+ if (char === ']') {
+ inRegexCharClass = false
+ }
+ continue
+ }
+
+ if (char === '[') {
+ inRegexCharClass = true
+ continue
+ }
+
+ if (char === '/') {
+ inRegex = false
+ lastToken = createLiteralToken()
+ }
+ continue
+ }
+
+ if (inSingleQuote) {
+ sanitized.push(maskChar(char))
+ if (char === '\\') {
+ sanitized.push(maskChar(next))
+ i++
+ } else if (char === "'") {
+ inSingleQuote = false
+ lastToken = createLiteralToken()
+ }
+ continue
+ }
+
+ if (inDoubleQuote) {
+ sanitized.push(maskChar(char))
+ if (char === '\\') {
+ sanitized.push(maskChar(next))
+ i++
+ } else if (char === '"') {
+ inDoubleQuote = false
+ lastToken = createLiteralToken()
+ }
+ continue
+ }
+
+ if (inTemplate && templateExpressionDepth === 0) {
+ sanitized.push(maskChar(char))
+ if (char === '\\') {
+ sanitized.push(maskChar(next))
+ i++
+ } else if (char === '`') {
+ inTemplate = false
+ lastToken = createLiteralToken()
+ } else if (char === '$' && next === '{') {
+ sanitized.push(maskChar(next))
+ templateExpressionDepth = 1
+ i++
+ lastToken = null
+ }
+ continue
+ }
+
+ if (char === '/' && next === '/') {
+ sanitized.push(maskChar(char))
+ sanitized.push(maskChar(next))
+ inLineComment = true
+ i++
+ continue
+ }
+
+ if (char === '/' && next === '*') {
+ sanitized.push(maskChar(char))
+ sanitized.push(maskChar(next))
+ inBlockComment = true
+ i++
+ continue
+ }
+
+ if (char === '/' && isRegexStart(lastToken, next)) {
+ sanitized.push(maskChar(char))
+ inRegex = true
+ inRegexCharClass = false
+ continue
+ }
+
+ if (char === "'") {
+ sanitized.push(maskChar(char))
+ inSingleQuote = true
+ continue
+ }
+
+ if (char === '"') {
+ sanitized.push(maskChar(char))
+ inDoubleQuote = true
+ continue
+ }
+
+ if (char === '`') {
+ sanitized.push(maskChar(char))
+ inTemplate = true
+ continue
+ }
+
+ if (inTemplate && templateExpressionDepth > 0) {
+ if (char === '{') {
+ templateExpressionDepth++
+ sanitized.push(char)
+ lastToken = {
+ type: 'char',
+ value: '{'
+ }
+ continue
+ }
+
+ if (char === '}') {
+ if (templateExpressionDepth === 1) {
+ sanitized.push(maskChar(char))
+ lastToken = createLiteralToken()
+ } else {
+ sanitized.push(char)
+ lastToken = {
+ type: 'char',
+ value: '}'
+ }
+ }
+ templateExpressionDepth--
+ continue
+ }
+ }
+
+ if (isIdentifierStart(char)) {
+ const identifier = readIdentifier(text, i)
+ sanitized.push(identifier.value)
+ lastToken = {
+ type: 'word',
+ value: identifier.value
+ }
+ i = identifier.end - 1
+ continue
+ }
+
+ sanitized.push(char)
+
+ if (!/\s/.test(char)) {
+ lastToken = {
+ type: 'char',
+ value: char
+ }
+ }
+ }
+
+ return sanitized.join('')
+}
+
+function detectScopeFromHeader(header) {
+ const trimmedHeader = header.trimEnd()
+
+ const classMatch = trimmedHeader.match(/class\s+([A-Za-z_$][\w$]*)[^{]*$/)
+ if (classMatch) {
+ return {
+ type: 'class',
+ name: classMatch[1]
+ }
+ }
+
+ const functionPatterns = [
+ /function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)[^{]*$/,
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>[^{]*$/,
+ /(?:^|[\n;]\s*)(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*$/
+ ]
+
+ for (const pattern of functionPatterns) {
+ const match = trimmedHeader.match(pattern)
+ if (!match) {
+ continue
+ }
+
+ const name = match[1]
+ if (!name || CONTROL_KEYWORDS.has(name)) {
+ continue
+ }
+
+ return {
+ type: 'function',
+ name
+ }
+ }
+
+ return {
+ type: 'other',
+ name: ''
+ }
+}
+
+export function getOpenScopeContext(text = '') {
+ const sanitizedText = sanitizeStructuralText(text)
+ const scopeStack = []
+
+ for (let i = 0; i < sanitizedText.length; i++) {
+ const char = sanitizedText[i]
+
+ if (char === '{') {
+ const header = sanitizedText.slice(Math.max(0, i - 200), i)
+ const scope = detectScopeFromHeader(header)
+ scopeStack.push(scope)
+ continue
+ }
+
+ if (char === '}' && scopeStack.length > 0) {
+ scopeStack.pop()
+ }
+ }
+
+ const functionScope = [...scopeStack].reverse().find((scope) => scope.type === 'function')
+ const classScope = [...scopeStack].reverse().find((scope) => scope.type === 'class')
+
+ return {
+ functionName: functionScope?.name || '',
+ className: classScope?.name || ''
+ }
+}
diff --git a/packages/plugins/script/src/ai-completion/utils/modelUtils.js b/packages/plugins/script/src/ai-completion/utils/modelUtils.js
new file mode 100644
index 0000000000..5f0e260d8d
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/utils/modelUtils.js
@@ -0,0 +1,99 @@
+import { MODEL_CONFIG, MODEL_COMMON_CONFIG, STOP_SEQUENCES, CONTEXT_STOP_SEQUENCES } from '../constants.js'
+
+/**
+ * 检测模型类型
+ * @param {string} modelName - 模型名称
+ * @param {Object} options - 额外上下文
+ * @param {Object} options.capabilities - 模型能力
+ * @returns {'qwen' | 'deepseek' | 'unknown'} 模型类型
+ */
+export function detectModelType(modelName, options = {}) {
+ if (!modelName) return MODEL_CONFIG.UNKNOWN.TYPE
+
+ const { capabilities = {} } = options
+ if (capabilities?.completionProtocol) {
+ return capabilities.completionProtocol
+ }
+
+ const lowerName = modelName.toLowerCase()
+ const isQwenCompletionModel =
+ MODEL_CONFIG.QWEN.COMPLETION_MODELS.some((item) => item === lowerName) ||
+ MODEL_CONFIG.QWEN.COMPLETION_MODEL_PATTERNS.some((pattern) => pattern.test(lowerName))
+ const isDeepSeekCompletionModel =
+ MODEL_CONFIG.DEEPSEEK.COMPLETION_MODELS.some((item) => item === lowerName) ||
+ MODEL_CONFIG.DEEPSEEK.COMPLETION_MODEL_PATTERNS.some((pattern) => pattern.test(lowerName))
+
+ if (isQwenCompletionModel) {
+ return MODEL_CONFIG.QWEN.TYPE
+ }
+
+ if (isDeepSeekCompletionModel) {
+ return MODEL_CONFIG.DEEPSEEK.TYPE
+ }
+
+ return MODEL_CONFIG.UNKNOWN.TYPE
+}
+
+/**
+ * 计算动态 Token 数量
+ * @param {Object} cursorContext - 光标上下文
+ * @returns {number} Token 数量
+ */
+export function calculateTokens(cursorContext) {
+ const limits = MODEL_COMMON_CONFIG.TOKEN_LIMITS
+
+ if (!cursorContext) {
+ return limits.DEFAULT
+ }
+
+ if (cursorContext.needsStatement) {
+ return limits.STATEMENT
+ } else if (cursorContext.needsExpression) {
+ return limits.EXPRESSION
+ } else if (cursorContext.inFunction) {
+ return limits.FUNCTION
+ } else if (cursorContext.inClass) {
+ return limits.CLASS
+ }
+
+ return limits.DEFAULT
+}
+
+// 获取动态停止符(最多 16 个)
+export function getStopSequences(cursorContext) {
+ const stops = []
+
+ // 核心停止符
+ stops.push(...STOP_SEQUENCES.CORE)
+
+ if (cursorContext) {
+ if (cursorContext.inBlockComment || cursorContext.inLineComment) {
+ stops.push(...CONTEXT_STOP_SEQUENCES.COMMENT)
+ } else if (cursorContext.needsExpression) {
+ stops.push(...CONTEXT_STOP_SEQUENCES.EXPRESSION)
+ stops.push(...STOP_SEQUENCES.NEW_SCOPE)
+ } else if (cursorContext.inObject) {
+ stops.push(...CONTEXT_STOP_SEQUENCES.OBJECT)
+ stops.push(...STOP_SEQUENCES.NEW_SCOPE)
+ } else if (cursorContext.inFunction) {
+ stops.push(...CONTEXT_STOP_SEQUENCES.FUNCTION)
+ stops.push(...STOP_SEQUENCES.BLOCK_END)
+ } else {
+ stops.push(...STOP_SEQUENCES.NEW_SCOPE)
+ stops.push(...STOP_SEQUENCES.BLOCK_END)
+ }
+ } else {
+ stops.push(...STOP_SEQUENCES.NEW_SCOPE)
+ stops.push(...STOP_SEQUENCES.BLOCK_END)
+ }
+
+ const uniqueStops = [...new Set(stops)]
+
+ if (uniqueStops.length > 16) {
+ // eslint-disable-next-line no-console
+ console.warn(`⚠️ 停止符超过限制: ${uniqueStops.length},截断到 16 个`)
+ return uniqueStops.slice(0, 16)
+ }
+
+ return uniqueStops
+}
diff --git a/packages/plugins/script/src/ai-completion/utils/requestUtils.js b/packages/plugins/script/src/ai-completion/utils/requestUtils.js
new file mode 100644
index 0000000000..d3445982e1
--- /dev/null
+++ b/packages/plugins/script/src/ai-completion/utils/requestUtils.js
@@ -0,0 +1,57 @@
+import { HTTP_CONFIG } from '../constants.js'
+
+function createAbortError(message) {
+ const error = new Error(message)
+ error.name = 'AbortError'
+ return error
+}
+
+export async function fetchWithTimeout(url, options = {}, timeoutMs = HTTP_CONFIG.REQUEST_TIMEOUT_MS, externalSignal) {
+ const controller = new AbortController()
+ let timedOut = false
+ let timeoutId
+
+ const abortFromExternalSignal = () => {
+ if (!controller.signal.aborted) {
+ controller.abort()
+ }
+ }
+
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
+ timeoutId = setTimeout(() => {
+ timedOut = true
+ controller.abort()
+ }, timeoutMs)
+ }
+
+ if (externalSignal) {
+ if (externalSignal.aborted) {
+ controller.abort()
+ } else {
+ externalSignal.addEventListener('abort', abortFromExternalSignal, { once: true })
+ }
+ }
+
+ try {
+ return await fetch(url, {
+ ...options,
+ signal: controller.signal
+ })
+ } catch (error) {
+ if (controller.signal.aborted && timedOut) {
+ throw createAbortError(`Request timed out after ${timeoutMs}ms`)
+ }
+
+ if (controller.signal.aborted && externalSignal?.aborted) {
+ throw createAbortError('Request aborted')
+ }
+
+ throw error
+ } finally {
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ }
+
+ externalSignal?.removeEventListener('abort', abortFromExternalSignal)
+ }
+}
diff --git a/packages/plugins/script/test/test.ts b/packages/plugins/script/test/test.ts
deleted file mode 100644
index e69de29bb2..0000000000