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