diff --git a/package-lock.json b/package-lock.json index f465e1f..94198f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@quasar/extras": "^1.14.0", "core-js": "^3.6.5", "croner": "^4.3.9", + "dompurify": "^3.2.4", "marked": "^15.0.7", "monaco-editor": "^0.33.0", "monaco-editor-webpack-plugin": "^7.0.1", @@ -2488,6 +2489,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/webpack-bundle-analyzer": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@types/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.7.0.tgz", @@ -4625,6 +4633,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -13085,6 +13102,12 @@ "@types/node": "*" } }, + "@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "@types/webpack-bundle-analyzer": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@types/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.7.0.tgz", @@ -14597,6 +14620,14 @@ "domelementtype": "^2.2.0" } }, + "dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "requires": { + "@types/trusted-types": "^2.0.7" + } + }, "domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", diff --git a/package.json b/package.json index 8d94576..c5437be 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@quasar/extras": "^1.14.0", "core-js": "^3.6.5", "croner": "^4.3.9", + "dompurify": "^3.2.4", "marked": "^15.0.7", "monaco-editor": "^0.33.0", "monaco-editor-webpack-plugin": "^7.0.1", diff --git a/plugin/lib/ai.js b/plugin/lib/ai.js index 17c1eb7..6eb3cd7 100644 --- a/plugin/lib/ai.js +++ b/plugin/lib/ai.js @@ -1,155 +1,303 @@ -const axios = require("axios"); - // 支持的模型类型 -const MODEL_TYPES = { +const API_TYPES = { OPENAI: "openai", OLLAMA: "ollama", }; -// 预设提示词 -const PRESET_PROMPTS = { +// 角色提示词 +const ROLE_PROMPTS = { // 翻译 - translate: `请将以下内容翻译成地道的中文,要求: -1. 保持原文的专业性和准确性 -2. 符合中文的表达习惯 -3. 对于专业术语保留英文原文,并在括号中给出中文翻译 -4. 保持原文的段落格式 - -原文:`, + translate: `你是一名翻译专家,请将我给你的内容进行翻译,要求: +1. 无论给的内容长短,请直接翻译,不要进行任何解释 +2. 提供中文时,翻译成地道的英文,符合英文的表达习惯 +3. 提供英文时,翻译成地道的中文,符合中文的表达习惯 +4. 保持原文的专业性和准确性 +5. 对于专业术语保留原文,并在括号中给出对应的中文翻译 +6. 保持原文的段落格式 +`, // 生成SHELL命令 - shell: `请根据以下描述生成一个 shell 命令,要求: + shell: `你是一名shell命令专家,请根据我的描述生成 shell 命令,要求: 1. 命令应当简洁高效 2. 优先使用常见的命令行工具 3. 确保命令的安全性和可靠性 4. 对于复杂操作,添加注释说明 5. 如果需要多个命令,使用 && 连接或使用脚本格式 6. 直接输出命令,不要输出任何解释,不要使用markdown格式 - -需求描述:`, +`, // 总结 - summarize: `请总结以下内容的要点,要求: + summarize: `你是一名总结专家,请总结我给你的内容的要点,要求: 1. 提取最重要和最有价值的信息 2. 使用简洁的语言 3. 按重要性排序 4. 保持逻辑性和连贯性 5. 如果有专业术语,保留并解释 - -原文:`, +`, }; +// API URL 处理 +const API_ENDPOINTS = { + [API_TYPES.OPENAI]: { + chat: "/v1/chat/completions", + models: "/v1/models", + }, + [API_TYPES.OLLAMA]: { + chat: "/api/chat", + models: "/api/tags", + }, +}; + +// 构建API URL +function buildApiUrl(baseUrl, endpoint) { + if (!baseUrl.endsWith(endpoint)) { + return baseUrl.replace(/\/?$/, endpoint); + } + return baseUrl; +} + +// 构建请求配置 +function buildRequestConfig(apiConfig) { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; + + if (apiConfig.apiType === API_TYPES.OPENAI && apiConfig.apiToken) { + config.headers["Authorization"] = `Bearer ${apiConfig.apiToken}`; + } + + return config; +} + +// 构建请求数据 +function buildRequestData(content, apiConfig, stream = false) { + const { model } = apiConfig; + const { prompt, role, context = [] } = content; + const rolePrompt = ROLE_PROMPTS[role] || role; + + const roleMessage = rolePrompt + ? [ + { + role: "user", + content: rolePrompt, + }, + ] + : []; + + // 统一的消息格式处理 + const messages = [ + // 添加系统角色消息(如果有) + ...roleMessage, + // 添加上下文消息 + ...context.map((msg) => ({ + role: msg.role || "user", + content: msg.content, + })), + // 添加当前用户消息 + { + role: "user", + content: prompt, + }, + ]; + + return { + model, + messages, + stream, + }; +} + +// 处理普通响应 +function parseResponse(response, apiType) { + if (apiType === API_TYPES.OPENAI) { + if (!response.data.choices || !response.data.choices[0]) { + throw new Error("OpenAI 响应格式错误"); + } + return response.data.choices[0].message.content; + } else { + if (!response.data.message) { + throw new Error("Ollama 响应格式错误"); + } + return response.data.message.content; + } +} + +// 处理模型列表响应 +function parseModelsResponse(response, apiType) { + if (apiType === API_TYPES.OPENAI) { + if (!response.data.data) { + throw new Error("OpenAI 响应格式错误"); + } + return response.data.data.map((model) => model.id); + } else { + if (!response.data.models) { + throw new Error("Ollama 响应格式错误"); + } + return response.data.models.map((model) => model.name); + } +} + +// 处理 OpenAI 流式响应 +async function handleOpenAIStreamResponse(line, controller, onStream) { + if (line.startsWith("data: ")) { + const jsonStr = line.replace(/^data: /, ""); + if (jsonStr === "[DONE]") { + onStream("", controller, true); + return; + } + const json = JSON.parse(jsonStr); + const content = json.choices[0]?.delta?.content; + if (content) { + onStream(content, controller, false); + } + } +} + +// 处理 Ollama 流式响应 +async function handleOllamaStreamResponse(line, controller, onStream) { + const json = JSON.parse(line); + if (json.done) { + onStream("", controller, true); + return; + } + if (json.message?.content) { + onStream(json.message.content, controller, false); + } +} + +// 处理流式响应 +async function handleStreamResponse(response, apiConfig, controller, onStream) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.trim()) { + try { + if (apiConfig.apiType === API_TYPES.OPENAI) { + await handleOpenAIStreamResponse(line, controller, onStream); + } else { + await handleOllamaStreamResponse(line, controller, onStream); + } + } catch (e) { + console.error("解析响应失败:", e); + } + } + } + } + + // 处理剩余的缓冲区 + if (buffer.trim()) { + try { + if (apiConfig.apiType === API_TYPES.OPENAI) { + await handleOpenAIStreamResponse(buffer, controller, onStream); + } else { + await handleOllamaStreamResponse(buffer, controller, onStream); + } + } catch (e) { + console.error("解析剩余响应失败:", e); + } + } + } catch (error) { + if (error.name === "AbortError") { + return { + success: false, + error: "请求已取消", + cancelled: true, + }; + } + throw error; + } finally { + reader.releaseLock(); + } + + return { success: true, result: "流式请求完成" }; +} + /** * AI对话功能 - * @param {Object} apiConfig - API配置参数 - * @param {string} apiConfig.modelType - 模型类型(openai/ollama) - * @param {string} apiConfig.apiUrl - API地址 - * @param {string} apiConfig.apiToken - API令牌 - * @param {string} apiConfig.model - 模型名称 * @param {Object} content - 对话内容参数 - * @param {string} content.prompt - 用户输入的提示词 - * @param {string} content.presetPrompt - 预设提示词类型 + * @param {Object} apiConfig - API配置参数 + * @param {Object} options - 其他选项 * @returns {Promise} 对话响应 */ -async function chat(content, apiConfig) { +async function chat(content, apiConfig, options = {}) { try { - const { modelType, apiUrl, apiToken, model } = apiConfig; - const { prompt, presetPrompt } = content; + const { showLoadingBar = true, stream = false, onStream } = options; // 验证必要参数 - if (!apiUrl || !prompt || !model) { + if (!apiConfig.apiUrl || !content.prompt || !apiConfig.model) { throw new Error("API地址、模型名称和提示词不能为空"); } - // 构建完整提示词 - const fullPrompt = presetPrompt - ? `${PRESET_PROMPTS[presetPrompt]}\n${prompt}` - : prompt; + if (stream && !onStream) { + throw new Error("使用流式请求时必须提供onStream回调函数"); + } - // 准备请求配置 - const config = { - headers: { - "Content-Type": "application/json", - }, - }; + // 构建请求URL和配置 + const url = buildApiUrl( + apiConfig.apiUrl, + API_ENDPOINTS[apiConfig.apiType].chat + ); + const config = buildRequestConfig(apiConfig, stream); + const requestData = buildRequestData(content, apiConfig, stream); - let requestData; - let url = apiUrl; - - // 根据不同的模型类型构建请求数据 - if (modelType === MODEL_TYPES.OPENAI) { - // OpenAI API - config.headers["Authorization"] = `Bearer ${apiToken}`; - requestData = { - model: model, - messages: [ - { - role: "user", - content: fullPrompt, + // 显示加载条 + const loadingBar = showLoadingBar + ? await quickcommand.showLoadingBar({ + text: "AI思考中...", + onClose: () => { + if (controller) { + controller.abort(); + } }, - ], - }; - } else if (modelType === MODEL_TYPES.OLLAMA) { - // Ollama API - // 如果用户没有指定完整的 API 路径,添加 /api/generate - if (!url.endsWith("/api/generate")) { - url = url.replace(/\/?$/, "/api/generate"); - } + }) + : null; - requestData = { - model: model, - prompt: fullPrompt, - stream: false, - }; - } else { - throw new Error("不支持的模型类型"); + // 统一使用 fetch 处理请求 + const controller = new AbortController(); + const response = await fetch(url, { + method: "POST", + headers: config.headers, + body: JSON.stringify(requestData), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); } - const loadingBar = await quickcommand.showLoadingBar({ - text: "AI思考中...", - onClose: () => { - // 取消请求 - if (source) { - source.cancel("操作已取消"); - } - }, - }); - - // 创建取消令牌 - const CancelToken = axios.CancelToken; - const source = CancelToken.source(); - - // 发送请求 - const response = await axios.post(url, requestData, { - ...config, - cancelToken: source.token, - }); - - loadingBar.close(); - - // 解析不同模型的响应 let result; - if (modelType === MODEL_TYPES.OPENAI) { - // OpenAI 响应格式 - if (!response.data.choices || !response.data.choices[0]) { - throw new Error("OpenAI 响应格式错误"); - } - result = response.data.choices[0].message.content; + if (stream) { + result = await handleStreamResponse( + response, + apiConfig, + controller, + onStream + ); } else { - // Ollama 响应格式 - if (!response.data.response) { - throw new Error("Ollama 响应格式错误"); - } - result = response.data.response; + const responseData = await response.json(); + result = { + success: true, + result: parseResponse({ data: responseData }, apiConfig.apiType), + }; } - return { - success: true, - result, - }; + loadingBar?.close(); + return result; } catch (error) { - // 如果是用户取消的请求,返回特定的错误信息 - if (axios.isCancel(error)) { + if (error.name === "AbortError") { return { success: false, error: "请求已取消", @@ -166,69 +314,33 @@ async function chat(content, apiConfig) { /** * 获取API支持的模型列表 * @param {Object} apiConfig - API配置参数 - * @param {string} apiConfig.modelType - 模型类型(openai/ollama) - * @param {string} apiConfig.apiUrl - API地址 - * @param {string} apiConfig.apiToken - API令牌 * @returns {Promise} 模型列表响应 */ async function getModels(apiConfig) { try { - const { modelType, apiUrl, apiToken } = apiConfig; - - // 验证必要参数 - if (!apiUrl) { + if (!apiConfig.apiUrl) { throw new Error("API地址不能为空"); } - // 准备请求配置 - const config = { - headers: { - "Content-Type": "application/json", - }, - }; + const url = buildApiUrl( + apiConfig.apiUrl, + API_ENDPOINTS[apiConfig.apiType].models + ); + const config = buildRequestConfig(apiConfig); - let url = apiUrl; + const response = await fetch(url, { + method: "GET", + headers: config.headers, + }); - // 根据不同的模型类型构建请求 - if (modelType === MODEL_TYPES.OPENAI) { - // OpenAI API - config.headers["Authorization"] = `Bearer ${apiToken}`; - // OpenAI的模型列表接口是 /v1/models - if (!url.endsWith("/models")) { - url = "https://api.openai.com/v1/models"; - } - } else if (modelType === MODEL_TYPES.OLLAMA) { - // Ollama API - // Ollama的模型列表接口是 /api/tags - if (!url.endsWith("/api/tags")) { - url = url.replace(/\/?$/, "/api/tags"); - } - } else { - throw new Error("不支持的模型类型"); - } - - // 发送请求 - const response = await axios.get(url, config); - - // 解析不同模型的响应 - let models; - if (modelType === MODEL_TYPES.OPENAI) { - // OpenAI 响应格式 - if (!response.data.data) { - throw new Error("OpenAI 响应格式错误"); - } - models = response.data.data.map((model) => model.id); - } else { - // Ollama 响应格式 - if (!response.data.models) { - throw new Error("Ollama 响应格式错误"); - } - models = response.data.models.map((model) => model.name); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); } + const responseData = await response.json(); return { success: true, - result: models, + result: parseModelsResponse({ data: responseData }, apiConfig.apiType), }; } catch (error) { return { diff --git a/plugin/lib/quickcommand.js b/plugin/lib/quickcommand.js index 183e6f2..66384b3 100644 --- a/plugin/lib/quickcommand.js +++ b/plugin/lib/quickcommand.js @@ -188,8 +188,8 @@ const quickcommand = { return null; }, - askAI: async function (content, apiConfig) { - return await chat(content, apiConfig); + askAI: async function (content, apiConfig, options) { + return await chat(content, apiConfig, options); }, ...systemDialog, diff --git a/src/components/CommandEditor.vue b/src/components/CommandEditor.vue index d815fdb..a480fdc 100644 --- a/src/components/CommandEditor.vue +++ b/src/components/CommandEditor.vue @@ -21,7 +21,6 @@ v-if="!isRunCodePage" v-model="commandManager.state.currentCommand" from="quickcommand" - @update:is-expanded="isConfigExpanded = $event" :expand-on-focus="true" class="command-config" /> @@ -97,7 +96,6 @@ export default { programLanguages: Object.keys(programs), showComposer: false, listener: null, - isConfigExpanded: false, composerInfo: { program: "quickcomposer", }, diff --git a/src/components/ai/AISelector.vue b/src/components/ai/AISelector.vue new file mode 100644 index 0000000..01e8836 --- /dev/null +++ b/src/components/ai/AISelector.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/components/composer/ai/AskAIEditor.vue b/src/components/composer/ai/AskAIEditor.vue index 1b8a959..b4da6a0 100644 --- a/src/components/composer/ai/AskAIEditor.vue +++ b/src/components/composer/ai/AskAIEditor.vue @@ -1,42 +1,26 @@ @@ -46,8 +30,7 @@ import ButtonGroup from "components/composer/common/ButtonGroup.vue"; import { newVarInputVal } from "js/composer/varInputValManager"; import VariableInput from "components/composer/common/VariableInput.vue"; import { parseFunction, stringifyArgv } from "js/composer/formatString"; -import { dbManager } from "js/utools.js"; - +import AISelector from "components/ai/AISelector.vue"; export default defineComponent({ name: "AskAIEditor", props: { @@ -56,25 +39,26 @@ export default defineComponent({ components: { VariableInput, ButtonGroup, + AISelector, }, emits: ["update:modelValue"], data() { return { + showAIConfig: false, defaultArgvs: { content: { prompt: newVarInputVal("str"), - presetPrompt: "", + role: "", }, apiConfig: {}, }, - apiOptions: [], - presetPromptOptions: [ - { label: "自由问答", value: "" }, + roleOptions: [ + { label: "无", value: "" }, { label: "翻译", value: "translate" }, { label: "总结", value: "summarize" }, - { label: "执行shell命令", value: "shell" }, + { label: "生成shell命令", value: "shell" }, ], - modelTypeOptions: [ + apiTypeOptions: [ { label: "OpenAI", value: "openai" }, { label: "Ollama", value: "ollama" }, ], @@ -125,23 +109,6 @@ export default defineComponent({ }); }, }, - mounted() { - const apiConfigs = dbManager.getStorage("cfg_aiConfigs"); - this.apiOptions = apiConfigs - ? apiConfigs.map((config) => { - return { - label: config.name, - value: config, - }; - }) - : []; - this.defaultArgvs.apiConfig = apiConfigs?.[0] || {}; - - const argvs = this.modelValue.argvs || this.defaultArgvs; - if (!this.modelValue.code) { - this.updateModelValue(argvs); - } - }, }); diff --git a/src/components/editor/AIAssistantDialog.vue b/src/components/editor/AIAssistantDialog.vue new file mode 100644 index 0000000..c70a354 --- /dev/null +++ b/src/components/editor/AIAssistantDialog.vue @@ -0,0 +1,404 @@ + + + + + diff --git a/src/components/editor/CodeEditor.vue b/src/components/editor/CodeEditor.vue index 06a94b8..3ee6ff5 100644 --- a/src/components/editor/CodeEditor.vue +++ b/src/components/editor/CodeEditor.vue @@ -6,6 +6,26 @@ {{ placeholder }} + +
+ + AI 助手 + +
+ + + + @@ -13,6 +33,7 @@ import * as monaco from "monaco-editor"; import importAll from "js/common/importAll.js"; import { defineComponent } from "vue"; +import AIAssistantDialog from "./AIAssistantDialog.vue"; // 批量导入关键字补全 let languageCompletions = importAll( @@ -39,6 +60,9 @@ const typeDefinitions = { export default defineComponent({ name: "CodeEditor", + components: { + AIAssistantDialog, + }, props: { // v-model 绑定值 modelValue: { @@ -134,6 +158,7 @@ export default defineComponent({ // 光标样式 cursorStyle: "line", }, + showAIDialog: false, }; }, watch: { @@ -394,6 +419,9 @@ export default defineComponent({ formatDocument() { editor.getAction("editor.action.formatDocument").run(); }, + setEditorValue(value) { + editor.setValue(value); + }, }, computed: { showPlaceholder() { @@ -431,4 +459,11 @@ export default defineComponent({ user-select: none; opacity: 0.4; } + +.ai-button-wrapper { + position: absolute; + right: 30px; + bottom: 30px; + z-index: 500; +} diff --git a/src/components/editor/CommandConfig.vue b/src/components/editor/CommandConfig.vue index bbc971c..47abd9e 100644 --- a/src/components/editor/CommandConfig.vue +++ b/src/components/editor/CommandConfig.vue @@ -1,7 +1,6 @@