diff --git a/package-lock.json b/package-lock.json index 816c5bd..41e4332 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openmcp", - "version": "0.0.6", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openmcp", - "version": "0.0.6", + "version": "0.0.8", "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2", "@seald-io/nedb": "^4.1.1", @@ -24,6 +24,7 @@ "@types/showdown": "^2.0.0", "@types/vscode": "^1.72.0", "copy-webpack-plugin": "^13.0.0", + "null-loader": "^4.0.1", "ts-loader": "^9.5.1", "typescript": "^5.4.2", "webpack": "^5.99.5", @@ -570,6 +571,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bmp-js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", @@ -980,6 +991,16 @@ "integrity": "sha512-kL4+wUTD7RSA5FHx5YwWtjDnEEkIIikFgWHR4P6fqjw1PPLlqYkxeOb++wAauAssat0YClCy8Y3C5SxgSkjibQ==", "dev": true }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1266,6 +1287,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.0.6.tgz", @@ -1870,6 +1898,19 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz", @@ -1897,6 +1938,21 @@ "node": ">=6.11.5" } }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, "node_modules/localforage": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", @@ -2064,6 +2120,80 @@ "node": ">=0.10.0" } }, + "node_modules/null-loader": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/null-loader/-/null-loader-4.0.1.tgz", + "integrity": "sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/null-loader/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/null-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/null-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/null-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2305,6 +2435,16 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -3037,6 +3177,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/package.json b/package.json index 0b1f844..eae6123 100644 --- a/package.json +++ b/package.json @@ -219,7 +219,8 @@ "pretest": "npm run compile && npm run lint", "lint": "eslint src --ext ts", "test": "node ./out/test/runTest.js", - "prepare:ocr": "webpack --config webpack/webpack.tesseract.js" + "prepare:ocr": "webpack --config webpack/webpack.tesseract.js", + "build:task-loop": "webpack --config webpack/webpack.task-loop.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2", @@ -238,6 +239,7 @@ "@types/showdown": "^2.0.0", "@types/vscode": "^1.72.0", "copy-webpack-plugin": "^13.0.0", + "null-loader": "^4.0.1", "ts-loader": "^9.5.1", "typescript": "^5.4.2", "webpack": "^5.99.5", diff --git a/renderer/src/components/main-panel/chat/chat-box/chat.ts b/renderer/src/components/main-panel/chat/chat-box/chat.ts index a295e04..03e5cc3 100644 --- a/renderer/src/components/main-panel/chat/chat-box/chat.ts +++ b/renderer/src/components/main-panel/chat/chat-box/chat.ts @@ -1,4 +1,4 @@ -import { ToolCallContent, ToolItem } from "@/hook/type"; +import type { ToolCallContent, ToolItem } from "@/hook/type"; import { Ref, ref } from "vue"; import type { OpenAI } from 'openai'; diff --git a/renderer/src/components/main-panel/chat/core/task-loop-sdk.ts b/renderer/src/components/main-panel/chat/core/task-loop-sdk.ts new file mode 100644 index 0000000..6c988b5 --- /dev/null +++ b/renderer/src/components/main-panel/chat/core/task-loop-sdk.ts @@ -0,0 +1,357 @@ +/* eslint-disable */ +import type { Ref } from "vue"; +import { ToolCall, ChatStorage, getToolSchema, MessageState } from "../chat-box/chat"; +import { useMessageBridge } from "@/api/message-bridge"; +import type { OpenAI } from 'openai'; +import { llmManager, llms } from "@/views/setting/llm"; +import { pinkLog, redLog } from "@/views/setting/util"; +import { ElMessage } from "element-plus"; +import { handleToolCalls } from "./handle-tool-calls"; + +export type ChatCompletionChunk = OpenAI.Chat.Completions.ChatCompletionChunk; +export type ChatCompletionCreateParamsBase = OpenAI.Chat.Completions.ChatCompletionCreateParams & { id?: string }; +interface TaskLoopOptions { + maxEpochs: number; + maxJsonParseRetry: number; +} + +interface IErrorMssage { + state: MessageState, + msg: string +} + +interface IDoConversationResult { + stop: boolean; +} + +/** + * @description 对任务循环进行的抽象封装 + */ +export class TaskLoop { + private bridge = useMessageBridge(); + private currentChatId = ''; + private completionUsage: ChatCompletionChunk['usage'] | undefined; + + constructor( + private readonly streamingContent: Ref, + private readonly streamingToolCalls: Ref, + private onError: (error: IErrorMssage) => void = (msg) => {}, + private onChunk: (chunk: ChatCompletionChunk) => void = (chunk) => {}, + private onDone: () => void = () => {}, + private onEpoch: () => void = () => {}, + private readonly taskOptions: TaskLoopOptions = { maxEpochs: 20, maxJsonParseRetry: 3 }, + ) { + + } + + private handleChunkDeltaContent(chunk: ChatCompletionChunk) { + const content = chunk.choices[0]?.delta?.content || ''; + if (content) { + this.streamingContent.value += content; + } + } + + private handleChunkDeltaToolCalls(chunk: ChatCompletionChunk) { + const toolCall = chunk.choices[0]?.delta?.tool_calls?.[0]; + + if (toolCall) { + const currentCall = this.streamingToolCalls.value[toolCall.index]; + + if (currentCall === undefined) { + // 新的工具调用开始 + this.streamingToolCalls.value[toolCall.index] = { + id: toolCall.id, + index: toolCall.index, + type: 'function', + function: { + name: toolCall.function?.name || '', + arguments: toolCall.function?.arguments || '' + } + }; + } else { + // 累积现有工具调用的信息 + if (currentCall) { + if (toolCall.id) { + currentCall.id = toolCall.id; + } + if (toolCall.function?.name) { + currentCall.function.name = toolCall.function.name; + } + if (toolCall.function?.arguments) { + currentCall.function.arguments += toolCall.function.arguments; + } + } + } + + } + } + + private handleChunkUsage(chunk: ChatCompletionChunk) { + const usage = chunk.usage; + if (usage) { + this.completionUsage = usage; + } + } + + private doConversation(chatData: ChatCompletionCreateParamsBase) { + + return new Promise((resolve, reject) => { + const chunkHandler = this.bridge.addCommandListener('llm/chat/completions/chunk', data => { + // data.code 一定为 200,否则不会走这个 route + const { chunk } = data.msg as { chunk: ChatCompletionChunk }; + + // 处理增量的 content 和 tool_calls + this.handleChunkDeltaContent(chunk); + this.handleChunkDeltaToolCalls(chunk); + this.handleChunkUsage(chunk); + + this.onChunk(chunk); + }, { once: false }); + + const doneHandler = this.bridge.addCommandListener('llm/chat/completions/done', data => { + this.onDone(); + chunkHandler(); + errorHandler(); + + resolve({ + stop: false + }); + }, { once: true }); + + const errorHandler = this.bridge.addCommandListener('llm/chat/completions/error', data => { + this.onError({ + state: MessageState.ReceiveChunkError, + msg: data.msg || '请求模型服务时发生错误' + }); + + chunkHandler(); + doneHandler(); + + resolve({ + stop: true + }); + + }, { once: true }); + + console.log(chatData); + + this.bridge.postMessage({ + command: 'llm/chat/completions', + data: JSON.parse(JSON.stringify(chatData)), + }); + }); + } + + public makeChatData(tabStorage: ChatStorage): ChatCompletionCreateParamsBase | undefined { + const baseURL = llms[llmManager.currentModelIndex].baseUrl; + const apiKey = llms[llmManager.currentModelIndex].userToken || ''; + + if (apiKey.trim() === '') { + + if (tabStorage.messages.length > 0 && tabStorage.messages[tabStorage.messages.length - 1].role === 'user') { + tabStorage.messages.pop(); + ElMessage.error('请先设置 API Key'); + } + return undefined; + } + + const model = llms[llmManager.currentModelIndex].userModel; + const temperature = tabStorage.settings.temperature; + const tools = getToolSchema(tabStorage.settings.enableTools); + + const userMessages = []; + if (tabStorage.settings.systemPrompt) { + userMessages.push({ + role: 'system', + content: tabStorage.settings.systemPrompt + }); + } + + // 如果超出了 tabStorage.settings.contextLength, 则删除最早的消息 + const loadMessages = tabStorage.messages.slice(- tabStorage.settings.contextLength); + userMessages.push(...loadMessages); + + // 增加一个id用于锁定状态 + const id = crypto.randomUUID(); + + const chatData = { + id, + baseURL, + apiKey, + model, + temperature, + tools, + messages: userMessages, + } as ChatCompletionCreateParamsBase; + + return chatData; + } + + public abort() { + this.bridge.postMessage({ + command: 'llm/chat/completions/abort', + data: { + id: this.currentChatId + } + }); + this.streamingContent.value = ''; + this.streamingToolCalls.value = []; + } + + public registerOnError(handler: (msg: IErrorMssage) => void) { + this.onError = handler; + } + + public registerOnChunk(handler: (chunk: ChatCompletionChunk) => void) { + this.onChunk = handler; + } + + public registerOnDone(handler: () => void) { + this.onDone = handler; + } + + public registerOnEpoch(handler: () => void) { + this.onEpoch = handler; + } + + public setMaxEpochs(maxEpochs: number) { + this.taskOptions.maxEpochs = maxEpochs; + } + + /** + * @description 开启循环,异步更新 DOM + */ + public async start(tabStorage: ChatStorage, userMessage: string) { + // 添加目前的消息 + tabStorage.messages.push({ + role: 'user', + content: userMessage, + extraInfo: { + created: Date.now(), + state: MessageState.Success, + serverName: llms[llmManager.currentModelIndex].id || 'unknown' + } + }); + + let jsonParseErrorRetryCount = 0; + + for (let i = 0; i < this.taskOptions.maxEpochs; ++ i) { + + this.onEpoch(); + + // 初始累计清空 + this.streamingContent.value = ''; + this.streamingToolCalls.value = []; + this.completionUsage = undefined; + + // 构造 chatData + const chatData = this.makeChatData(tabStorage); + + if (!chatData) { + this.onDone(); + break; + } + + this.currentChatId = chatData.id!; + + // 发送请求 + const doConverationResult = await this.doConversation(chatData); + + console.log(doConverationResult); + + + // 如果存在需要调度的工具 + if (this.streamingToolCalls.value.length > 0) { + + tabStorage.messages.push({ + role: 'assistant', + content: this.streamingContent.value || '', + tool_calls: this.streamingToolCalls.value, + extraInfo: { + created: Date.now(), + state: MessageState.Success, + serverName: llms[llmManager.currentModelIndex].id || 'unknown' + } + }); + + pinkLog('调用工具数量:' + this.streamingToolCalls.value.length); + + for (const toolCall of this.streamingToolCalls.value || []) { + const toolCallResult = await handleToolCalls(toolCall); + + if (toolCallResult.state === MessageState.ParseJsonError) { + // 如果是因为解析 JSON 错误,则重新开始 + tabStorage.messages.pop(); + jsonParseErrorRetryCount ++; + + redLog('解析 JSON 错误 ' + toolCall?.function?.arguments); + + // 如果因为 JSON 错误而失败太多,就只能中断了 + if (jsonParseErrorRetryCount >= this.taskOptions.maxJsonParseRetry) { + tabStorage.messages.push({ + role: 'assistant', + content: `解析 JSON 错误,无法继续调用工具 (累计错误次数 ${this.taskOptions.maxJsonParseRetry})`, + extraInfo: { + created: Date.now(), + state: toolCallResult.state, + serverName: llms[llmManager.currentModelIndex].id || 'unknown', + usage: undefined + } + }); + break; + } + } else if (toolCallResult.state === MessageState.Success) { + tabStorage.messages.push({ + role: 'tool', + index: toolCall.index || 0, + tool_call_id: toolCall.id || toolCall.function.name, + content: toolCallResult.content, + extraInfo: { + created: Date.now(), + state: toolCallResult.state, + serverName: llms[llmManager.currentModelIndex].id || 'unknown', + usage: this.completionUsage + } + }); + } else if (toolCallResult.state === MessageState.ToolCall) { + + tabStorage.messages.push({ + role: 'tool', + index: toolCall.index || 0, + tool_call_id: toolCall.id || toolCall.function.name, + content: toolCallResult.content, + extraInfo: { + created: Date.now(), + state: toolCallResult.state, + serverName: llms[llmManager.currentModelIndex].id || 'unknown', + usage: this.completionUsage + } + }); + } + } + + } else if (this.streamingContent.value) { + tabStorage.messages.push({ + role: 'assistant', + content: this.streamingContent.value, + extraInfo: { + created: Date.now(), + state: MessageState.Success, + serverName: llms[llmManager.currentModelIndex].id || 'unknown', + usage: this.completionUsage + } + }); + break; + + } else { + // 一些提示 + break; + } + + // 回答聚合完成后根据 stop 来决定是否提前中断 + if (doConverationResult.stop) { + break; + } + } + } +} \ No newline at end of file diff --git a/renderer/src/components/main-panel/chat/core/task-loop.ts b/renderer/src/components/main-panel/chat/core/task-loop.ts index 818856f..6c988b5 100644 --- a/renderer/src/components/main-panel/chat/core/task-loop.ts +++ b/renderer/src/components/main-panel/chat/core/task-loop.ts @@ -1,9 +1,8 @@ /* eslint-disable */ -import { Ref } from "vue"; +import type { Ref } from "vue"; import { ToolCall, ChatStorage, getToolSchema, MessageState } from "../chat-box/chat"; import { useMessageBridge } from "@/api/message-bridge"; import type { OpenAI } from 'openai'; -import { callTool } from "../../tool/tools"; import { llmManager, llms } from "@/views/setting/llm"; import { pinkLog, redLog } from "@/views/setting/util"; import { ElMessage } from "element-plus"; diff --git a/webpack/webpack.task-loop.js b/webpack/webpack.task-loop.js new file mode 100644 index 0000000..21a799b --- /dev/null +++ b/webpack/webpack.task-loop.js @@ -0,0 +1,44 @@ +const path = require('path'); +const TerserPlugin = require('terser-webpack-plugin'); + +module.exports = { + mode: 'production', + entry: './renderer/src/components/main-panel/chat/core/task-loop-sdk.ts', + output: { + path: path.resolve(__dirname, '../openmcp-sdk'), + filename: 'task-loop-sdk.js', + libraryTarget: 'commonjs2' + }, + target: 'node', + resolve: { + extensions: ['.ts', '.js'], + alias: { + '@': path.resolve(__dirname, '../renderer/src'), // 修正路径别名 + }, + }, + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + { + test: /\.vue$/, + use: { + loader: 'null-loader' + } + } + ], + }, + optimization: { + minimizer: [ + new TerserPlugin({ + extractComments: false, // 禁用提取许可证文件 + }), + ], + }, + externals: { + vue: 'vue', // 不打包 vue 库 + }, +}; \ No newline at end of file