Compare commits

...

5 Commits

Author SHA1 Message Date
e20bbf2b42 验证 task-loop 2025-05-10 20:10:23 +08:00
1fef3a1150 增加 task-loop 的打包 2025-05-09 23:55:34 +08:00
68f45fedf7 优化渲染结构 2025-05-09 18:53:15 +08:00
4c0566b470 支持一次对话同时调用多个工具 2025-05-08 22:28:44 +08:00
da482e26a9 修复一轮bug 2025-05-08 19:33:38 +08:00
56 changed files with 1011 additions and 373 deletions

View File

@ -1,5 +1,17 @@
# Change Log
## [main] 0.0.8
- 大模型 API 测试时更加完整的报错
- 修复 0.0.7 引入的bug修改对话无法发出
- 修复 bug富文本编辑器粘贴文本会带样式
- 修复 bug富文本编辑器发送前缀为空的字符会全部为空
- 修复 bug流式传输进行 function calling 时,多工具的索引串流导致的 JSON Schema 反序列化失败
- 修复 bug大模型返回大量重复错误信息
- 新特性:支持一次对话同时调用多个工具
- UI优化代码高亮的滚动条
- 新特性resources/list 协议的内容点击就会直接渲染,无需二次发送
- 新特性resources prompts tools 的结果的 json 模式支持高亮
## [main] 0.0.7
- 优化页面布局,使得调试窗口可以显示更多内容
- 扩大默认的上下文长度 10 -> 20

View File

@ -30,4 +30,9 @@ New-Item -ItemType Directory -Path ./software/openmcp-sdk -Force
Remove-Item -Recurse -Force ./software/openmcp-sdk/* -ErrorAction SilentlyContinue
Copy-Item -Recurse -Path ./openmcp-sdk -Destination ./software/ -Force
$serviceJob = Start-Job -ScriptBlock {
param($workDir)
npm run build:task-loop
} -ArgumentList $currentDir
Write-Output "finish building services in ./openmcp-sdk"

View File

@ -13,4 +13,6 @@ mkdir -p ./software/openmcp-sdk
rm -rf ./software/openmcp-sdk
cp -r ./openmcp-sdk ./software/
npm run build:task-loop
echo "finish building services in ./openmcp-sdk"

154
package-lock.json generated
View File

@ -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",

View File

@ -2,7 +2,7 @@
"name": "openmcp",
"displayName": "OpenMCP",
"description": "An all in one MCP Client/TestTool",
"version": "0.0.7",
"version": "0.0.8",
"publisher": "kirigaya",
"author": {
"name": "kirigaya",
@ -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",

View File

@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 4870215 */
src: url('iconfont.woff2?t=1746529081655') format('woff2'),
url('iconfont.woff?t=1746529081655') format('woff'),
url('iconfont.ttf?t=1746529081655') format('truetype');
src: url('iconfont.woff2?t=1746703816245') format('woff2'),
url('iconfont.woff?t=1746703816245') format('woff'),
url('iconfont.ttf?t=1746703816245') format('truetype');
}
.iconfont {
@ -13,6 +13,10 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-waiting:before {
content: "\e6d0";
}
.icon-timeout:before {
content: "\edf5";
}

Binary file not shown.

View File

@ -131,6 +131,10 @@ a {
height: 5px;
}
.tool-arguments .openmcp-code-block pre code::-webkit-scrollbar {
background: transparent !important;
}
.tool-arguments .openmcp-code-block pre code {
background: transparent !important;
}

View File

@ -1,6 +1,5 @@
import { pinkLog, redLog } from '@/views/setting/util';
import { acquireVsCodeApi, electronApi, getPlatform } from './platform';
import { ref } from 'vue';
export interface VSCodeMessage {
command: string;
@ -20,7 +19,7 @@ interface AddCommandListenerOption {
once: boolean // 只调用一次就销毁
}
class MessageBridge {
export class MessageBridge {
private ws: WebSocket | null = null;
private handlers = new Map<string, Set<CommandHandler>>();
private isConnected: Promise<boolean> | null = null;
@ -44,6 +43,11 @@ class MessageBridge {
pinkLog('当前模式: electron');
break;
case 'nodejs':
this.setupNodejsListener();
pinkLog('当前模式: nodejs');
break;
case 'web':
this.setupWebSocket();
pinkLog('当前模式: web');
@ -114,6 +118,17 @@ class MessageBridge {
};
}
private setupNodejsListener() {
const EventEmitter = require('events');
const eventEmitter = new EventEmitter();
this.postMessage = (message) => {
eventEmitter.emit('server', message);
};
}
/**
* @description message command
* @param message

View File

@ -1,14 +1,16 @@
export type OpenMcpSupportPlatform = 'web' | 'vscode' | 'electron';
export type OpenMcpSupportPlatform = 'web' | 'vscode' | 'electron' | 'nodejs';
export const acquireVsCodeApi = (window as any)['acquireVsCodeApi'];
export const electronApi = (window as any)['electronApi'];
export const isNodejs = (window as any)['nodejs'];
export function getPlatform(): OpenMcpSupportPlatform {
if (typeof acquireVsCodeApi !== 'undefined') {
if (acquireVsCodeApi) {
return 'vscode';
} else if (typeof electronApi !== 'undefined') {
} else if (electronApi) {
return 'electron';
} else if (isNodejs) {
return 'nodejs';
} else {
return 'web';
}

View File

@ -0,0 +1,22 @@
<template>
<el-scrollbar width="100%">
<div v-html="renderJson(json)">
</div>
</el-scrollbar>
</template>
<script setup lang="ts">
import { computed, defineProps, PropType } from 'vue';
import { renderJson } from '../main-panel/chat/markdown/markdown';
const props = defineProps({
json: {
type: Object as PropType<string | object | undefined>,
required: true
}
});
</script>
<style>
</style>

View File

@ -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';
@ -27,6 +27,7 @@ export interface IExtraInfo {
export interface ToolMessage {
role: 'tool';
index: number;
content: ToolCallContent[];
tool_call_id?: string
name?: string // 工具名称,当 role 为 tool
@ -95,15 +96,24 @@ export type RichTextItem = PromptTextItem | ResourceTextItem | TextItem;
export const allTools = ref<ToolItem[]>([]);
export interface IRenderMessage {
role: 'user' | 'assistant/content' | 'assistant/tool_calls' | 'tool';
export interface ICommonRenderMessage {
role: 'user' | 'assistant/content';
content: string;
toolResult?: ToolCallContent[];
tool_calls?: ToolCall[];
showJson?: Ref<boolean>;
extraInfo: IExtraInfo;
}
export interface IToolRenderMessage {
role: 'assistant/tool_calls';
content: string;
toolResults: ToolCallContent[][];
tool_calls: ToolCall[];
showJson?: Ref<boolean>;
extraInfo: IExtraInfo;
}
export type IRenderMessage = ICommonRenderMessage | IToolRenderMessage;
export function getToolSchema(enableTools: EnableToolItem[]) {
const toolsSchema = [];
for (let i = 0; i < enableTools.length; i++) {

View File

@ -61,6 +61,26 @@ const streamingContent = inject('streamingContent') as Ref<string>;
const streamingToolCalls = inject('streamingToolCalls') as Ref<ToolCall[]>;
const scrollToBottom = inject('scrollToBottom') as () => Promise<void>;
const updateScrollHeight = inject('updateScrollHeight') as () => void;
const chatContext = inject('chatContext') as any;
chatContext.handleSend = handleSend;
function clearErrorMessage(errorMessage: string) {
try {
const errorObject = JSON.parse(errorMessage);
if (errorObject.error) {
return errorObject.error;
}
if (errorObject.message) {
return errorObject.message;
}
if (errorObject.msg) {
return errorObject.msg;
}
} catch (error) {
return errorMessage;
}
}
function handleSend(newMessage?: string) {
//
@ -76,17 +96,16 @@ function handleSend(newMessage?: string) {
loop = new TaskLoop(streamingContent, streamingToolCalls);
loop.registerOnError((error) => {
ElMessage({
message: error.msg,
type: 'error',
duration: 3000
});
console.log('error.msg');
console.log(error.msg);
const errorMessage = clearErrorMessage(error.msg);
ElMessage.error(errorMessage);
if (error.state === MessageState.ReceiveChunkError) {
tabStorage.messages.push({
role: 'assistant',
content: error.msg,
content: errorMessage,
extraInfo: {
created: Date.now(),
state: error.state,
@ -125,8 +144,6 @@ function handleAbort() {
}
}
provide('handleSend', handleSend);
onMounted(() => {
updateScrollHeight();

View File

@ -7,7 +7,7 @@
<el-dialog v-model="showChooseResource" :title="t('resources')" width="400px">
<div class="resource-template-container-scrollbar" v-if="!selectResource">
<ResourceList :tab-id="-1" @resource-selected="resource => selectResource = resource" />
<ResourceList :tab-id="-1" @resource-selected="resource => handleResourceSelected(resource)" />
</div>
<div v-else>
<ResourceReader :tab-id="-1" :current-resource-name="selectResource!.name"
@ -25,13 +25,14 @@
import { createApp, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { ChatStorage, EditorContext } from '../chat';
import { ResourcesReadResponse, ResourceTemplate } from '@/hook/type';
import { Resources, ResourcesReadResponse, ResourceTemplate } from '@/hook/type';
import ResourceList from '@/components/main-panel/resource/resource-list.vue';
import ResourceReader from '@/components/main-panel/resource/resouce-reader.vue';
import { ElMessage, ElTooltip, ElProgress, ElPopover } from 'element-plus';
import ResourceChatItem from '../resource-chat-item.vue';
import { useMessageBridge } from '@/api/message-bridge';
const { t } = useI18n();
@ -57,6 +58,15 @@ function saveCursorPosition() {
}
}
async function handleResourceSelected(resource: Resources) {
selectResource.value = undefined;
const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: resource.uri });
if (msg) {
await whenGetResourceResponse(msg as ResourcesReadResponse);
}
}
async function whenGetResourceResponse(msg: ResourcesReadResponse) {
if (!msg) {
return;

View File

@ -10,6 +10,7 @@
class="rich-editor"
:placeholder="placeholder"
@input="handleInput"
@paste="handlePaste"
@keydown.backspace="handleBackspace"
@keydown.enter="handleKeydown"
@compositionstart="handleCompositionStart"
@ -171,6 +172,32 @@ function handleKeydown(event: KeyboardEvent) {
}
}
function handlePaste(event: ClipboardEvent) {
event.preventDefault(); //
const clipboardData = event.clipboardData;
if (clipboardData) {
const pastedText = clipboardData.getData('text/plain');
const editorElement = editor.value;
if (editorElement instanceof HTMLDivElement) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(pastedText);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
}
if (editor.value) {
editor.value.dispatchEvent(new Event('input'));
}
}
function handleCompositionStart() {
isComposing.value = true;
}

View File

@ -0,0 +1,81 @@
import { ToolCallResponse } from "@/hook/type";
import { callTool } from "../../tool/tools";
import { MessageState, ToolCall } from "../chat-box/chat";
export async function handleToolCalls(toolCall: ToolCall) {
// 反序列化 streaming 来的参数字符串
const toolName = toolCall.function.name;
const argsResult = deserializeToolCallResponse(toolCall.function.arguments);
if (argsResult.error) {
return {
content: [{
type: 'error',
text: parseErrorObject(argsResult.error)
}],
state: MessageState.ParseJsonError
};
}
const toolArgs = argsResult.value;
// 进行调用,根据结果返回不同的值
const toolResponse = await callTool(toolName, toolArgs);
return handleToolResponse(toolResponse);
}
function deserializeToolCallResponse(toolArgs: string) {
try {
const args = JSON.parse(toolArgs);
return {
value: args,
error: undefined
}
} catch (error) {
return {
value: undefined,
error
}
}
}
function handleToolResponse(toolResponse: ToolCallResponse) {
if (typeof toolResponse === 'string') {
// 如果是 string说明是错误信息
console.log(toolResponse);
return {
content: [{
type: 'error',
text: toolResponse
}],
state: MessageState.ToolCall
}
} else if (!toolResponse.isError) {
return {
content: toolResponse.content,
state: MessageState.Success
};
} else {
return {
content: toolResponse.content,
state: MessageState.ToolCall
};
}
}
function parseErrorObject(error: any): string {
if (typeof error === 'string') {
return error;
} else if (typeof error === 'object') {
return JSON.stringify(error, null, 2);
} else {
return error.toString();
}
}

View File

@ -1,24 +1,30 @@
/* 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";
import { handleToolCalls } from "./handle-tool-calls";
export type ChatCompletionChunk = OpenAI.Chat.Completions.ChatCompletionChunk;
export type ChatCompletionCreateParamsBase = OpenAI.Chat.Completions.ChatCompletionCreateParams & { id?: string };
interface TaskLoopOptions {
export interface TaskLoopOptions {
maxEpochs: number;
maxJsonParseRetry: number;
}
interface IErrorMssage {
export interface IErrorMssage {
state: MessageState,
msg: string
}
export interface IDoConversationResult {
stop: boolean;
}
/**
* @description
*/
@ -26,6 +32,7 @@ export class TaskLoop {
private bridge = useMessageBridge();
private currentChatId = '';
private completionUsage: ChatCompletionChunk['usage'] | undefined;
private llmConfig: any;
constructor(
private readonly streamingContent: Ref<string>,
@ -34,86 +41,9 @@ export class TaskLoop {
private onChunk: (chunk: ChatCompletionChunk) => void = (chunk) => {},
private onDone: () => void = () => {},
private onEpoch: () => void = () => {},
private readonly taskOptions: TaskLoopOptions = { maxEpochs: 20 },
private readonly taskOptions: TaskLoopOptions = { maxEpochs: 20, maxJsonParseRetry: 3 },
) {
}
private async handleToolCalls(toolCalls: ToolCall[]) {
// TODO: 调用多个工具并返回调用结果?
const toolCall = toolCalls[0];
let toolName: string;
let toolArgs: Record<string, any>;
try {
toolName = toolCall.function.name;
toolArgs = JSON.parse(toolCall.function.arguments);
} catch (error) {
return {
content: [{
type: 'error',
text: this.parseErrorObject(error)
}],
state: MessageState.ParseJsonError
};
}
try {
const toolResponse = await callTool(toolName, toolArgs);
console.log(toolResponse);
if (typeof toolResponse === 'string') {
console.log(toolResponse);
return {
content: [{
type: 'error',
text: toolResponse
}],
state: MessageState.ToolCall
}
} else if (!toolResponse.isError) {
return {
content: toolResponse.content,
state: MessageState.Success
};
} else {
return {
content: toolResponse.content,
state: MessageState.ToolCall
}
}
} catch (error) {
this.onError({
state: MessageState.ToolCall,
msg: `工具调用失败: ${(error as Error).message}`
});
console.error(error);
return {
content: [{
type: 'error',
text: this.parseErrorObject(error)
}],
state: MessageState.ToolCall
}
}
}
private parseErrorObject(error: any): string {
if (typeof error === 'string') {
return error;
} else if (typeof error === 'object') {
return JSON.stringify(error, null, 2);
} else {
return error.toString();
}
}
private handleChunkDeltaContent(chunk: ChatCompletionChunk) {
@ -131,15 +61,15 @@ export class TaskLoop {
if (currentCall === undefined) {
// 新的工具调用开始
this.streamingToolCalls.value = [{
this.streamingToolCalls.value[toolCall.index] = {
id: toolCall.id,
index: 0,
index: toolCall.index,
type: 'function',
function: {
name: toolCall.function?.name || '',
arguments: toolCall.function?.arguments || ''
}
}];
};
} else {
// 累积现有工具调用的信息
if (currentCall) {
@ -150,7 +80,7 @@ export class TaskLoop {
currentCall.function.name = toolCall.function.name;
}
if (toolCall.function?.arguments) {
currentCall.function.arguments += toolCall.function.arguments;
currentCall.function.arguments += toolCall.function.arguments;
}
}
}
@ -167,16 +97,9 @@ export class TaskLoop {
private doConversation(chatData: ChatCompletionCreateParamsBase) {
return new Promise<void>((resolve, reject) => {
return new Promise<IDoConversationResult>((resolve, reject) => {
const chunkHandler = this.bridge.addCommandListener('llm/chat/completions/chunk', data => {
if (data.code !== 200) {
this.onError({
state: MessageState.ReceiveChunkError,
msg: data.msg || '请求模型服务时发生错误'
});
resolve();
return;
}
// data.code 一定为 200否则不会走这个 route
const { chunk } = data.msg as { chunk: ChatCompletionChunk };
// 处理增量的 content 和 tool_calls
@ -187,13 +110,33 @@ export class TaskLoop {
this.onChunk(chunk);
}, { once: false });
this.bridge.addCommandListener('llm/chat/completions/done', data => {
const doneHandler = this.bridge.addCommandListener('llm/chat/completions/done', data => {
this.onDone();
chunkHandler();
errorHandler();
resolve();
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)),
@ -202,8 +145,8 @@ export class TaskLoop {
}
public makeChatData(tabStorage: ChatStorage): ChatCompletionCreateParamsBase | undefined {
const baseURL = llms[llmManager.currentModelIndex].baseUrl;
const apiKey = llms[llmManager.currentModelIndex].userToken || '';
const baseURL = this.getLlmConfig().baseUrl;
const apiKey = this.getLlmConfig().userToken || '';
if (apiKey.trim() === '') {
@ -214,7 +157,7 @@ export class TaskLoop {
return undefined;
}
const model = llms[llmManager.currentModelIndex].userModel;
const model = this.getLlmConfig().userModel;
const temperature = tabStorage.settings.temperature;
const tools = getToolSchema(tabStorage.settings.enableTools);
@ -273,6 +216,36 @@ export class TaskLoop {
this.onEpoch = handler;
}
public setMaxEpochs(maxEpochs: number) {
this.taskOptions.maxEpochs = maxEpochs;
}
/**
* @description LLM nodejs
* @param config
* @example
* setLlmConfig({
* id: 'openai',
* baseUrl: 'https://api.openai.com/v1',
* userToken: 'sk-xxx',
* userModel: 'gpt-3.5-turbo',
* })
*/
public setLlmConfig(config: any) {
this.llmConfig = config;
}
public getLlmConfig() {
if (this.llmConfig) {
return this.llmConfig;
}
return llms[llmManager.currentModelIndex];
}
public async connectToService() {
}
/**
* @description DOM
*/
@ -284,10 +257,12 @@ export class TaskLoop {
extraInfo: {
created: Date.now(),
state: MessageState.Success,
serverName: llms[llmManager.currentModelIndex].id || 'unknown'
serverName: this.getLlmConfig().id || 'unknown'
}
});
let jsonParseErrorRetryCount = 0;
for (let i = 0; i < this.taskOptions.maxEpochs; ++ i) {
this.onEpoch();
@ -308,7 +283,10 @@ export class TaskLoop {
this.currentChatId = chatData.id!;
// 发送请求
await this.doConversation(chatData);
const doConverationResult = await this.doConversation(chatData);
console.log(doConverationResult);
// 如果存在需要调度的工具
if (this.streamingToolCalls.value.length > 0) {
@ -320,54 +298,64 @@ export class TaskLoop {
extraInfo: {
created: Date.now(),
state: MessageState.Success,
serverName: llms[llmManager.currentModelIndex].id || 'unknown'
serverName: this.getLlmConfig().id || 'unknown'
}
});
pinkLog('调用工具数量:' + this.streamingToolCalls.value.length);
const toolCallResult = await this.handleToolCalls(this.streamingToolCalls.value);
console.log('toolCallResult', toolCallResult);
if (toolCallResult.state === MessageState.ParseJsonError) {
// 如果是因为解析 JSON 错误,则重新开始
tabStorage.messages.pop();
redLog('解析 JSON 错误 ' + this.streamingToolCalls.value[0]?.function?.arguments);
continue;
}
if (toolCallResult.state === MessageState.Success) {
const toolCall = this.streamingToolCalls.value[0];
tabStorage.messages.push({
role: 'tool',
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
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: this.getLlmConfig().id || 'unknown',
usage: undefined
}
});
break;
}
});
}
if (toolCallResult.state === MessageState.ToolCall) {
const toolCall = this.streamingToolCalls.value[0];
tabStorage.messages.push({
role: 'tool',
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.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: this.getLlmConfig().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: this.getLlmConfig().id || 'unknown',
usage: this.completionUsage
}
});
}
}
} else if (this.streamingContent.value) {
@ -377,7 +365,7 @@ export class TaskLoop {
extraInfo: {
created: Date.now(),
state: MessageState.Success,
serverName: llms[llmManager.currentModelIndex].id || 'unknown',
serverName: this.getLlmConfig().id || 'unknown',
usage: this.completionUsage
}
});
@ -385,7 +373,11 @@ export class TaskLoop {
} else {
// 一些提示
break;
}
// 回答聚合完成后根据 stop 来决定是否提前中断
if (doConverationResult.stop) {
break;
}
}

View File

@ -31,6 +31,18 @@ export function makeUsageStatistic(extraInfo: IExtraInfo): UsageStatistic | unde
total: usage.prompt_tokens + usage.completion_tokens,
cacheHitRatio: Math.ceil(usage.prompt_tokens_details?.cached_tokens || 0 / usage.prompt_tokens * 1000) / 10,
}
default:
if (usage.prompt_tokens && usage.completion_tokens) {
return {
input: usage.prompt_tokens,
output: usage.completion_tokens,
total: usage.prompt_tokens + usage.completion_tokens,
cacheHitRatio: Math.ceil((usage.prompt_tokens_details?.cached_tokens || 0) / usage.prompt_tokens * 1000) / 10,
}
}
return undefined;
}
return undefined;

View File

@ -25,7 +25,7 @@
<div class="message-content" v-else-if="message.role === 'assistant/tool_calls'">
<Message.Toolcall
:message="message" :tab-id="props.tabId"
@update:tool-result="(value, index) => (message.toolResult || [])[index] = value"
@update:tool-result="(value, toolIndex, index) => message.toolResults[toolIndex][index] = value"
/>
</div>
</div>
@ -97,6 +97,7 @@ const renderMessages = computed(() => {
messages.push({
role: 'assistant/tool_calls',
content: message.content,
toolResults: Array(message.tool_calls.length).fill([]),
tool_calls: message.tool_calls,
showJson: ref(false),
extraInfo: {
@ -116,8 +117,16 @@ const renderMessages = computed(() => {
// assistant
const lastAssistantMessage = messages[messages.length - 1];
if (lastAssistantMessage.role === 'assistant/tool_calls') {
lastAssistantMessage.toolResult = message.content;
lastAssistantMessage.extraInfo.state = message.extraInfo.state;
lastAssistantMessage.toolResults[message.index] = message.content;
if (lastAssistantMessage.extraInfo.state === MessageState.Unknown) {
lastAssistantMessage.extraInfo.state = message.extraInfo.state;
} else if (lastAssistantMessage.extraInfo.state === MessageState.Success
|| message.extraInfo.state !== MessageState.Success
) {
lastAssistantMessage.extraInfo.state = message.extraInfo.state;
}
lastAssistantMessage.extraInfo.usage = lastAssistantMessage.extraInfo.usage || message.extraInfo.usage;
}
}
@ -165,6 +174,11 @@ provide('streamingToolCalls', streamingToolCalls);
provide('isLoading', isLoading);
provide('autoScroll', autoScroll);
const chatContext = {
handleSend: undefined
};
provide('chatContext', chatContext);
// scrollToBottom
async function scrollToBottom() {
if (!scrollbarRef.value || !messageListRef.value) return;

View File

@ -10,41 +10,63 @@ function escapeHtml(unsafe: string) {
.replace(/'/g, "&#039;");
}
// 导出默认的 highlight 函数
export default function highlight(str: string, lang: string) {
// 创建代码块容器
let container = `<div class="openmcp-code-block">`;
// 添加复制按钮(右上角)
container += `
<div class="code-header">
<div class="code-language">${lang || ''}</div>
<button class="copy-button" onclick="copyCode(this)"></button>
</div>
`;
if (lang && Prism.languages[lang]) {
// 使用 Prism 高亮代码
const highlightedCode = Prism.highlight(str, Prism.languages[lang], lang);
// 添加代码区域
container += `<pre class="language-${lang}"><code class="language-${lang}">${highlightedCode}</code></pre>`;
} else {
// 普通代码块
container += `<pre class="language-none"><code>${escapeHtml(str)}</code></pre>`;
}
container += `</div>`;
return container;
interface HighlightOption {
needTools?: boolean
}
// 导出默认的 highlight 函数
export default function highlight(option: HighlightOption = {}) {
const {
needTools = true
} = option;
return (str: string, lang: string) => {
if (needTools) {
// 创建代码块容器
let container = `<div class="openmcp-code-block">`;
// 添加复制按钮(右上角)
container += `
<div class="code-header">
<div class="code-language">${lang || ''}</div>
<button class="copy-button" onclick="copyCode(this)"></button>
</div>
`;
if (lang && Prism.languages[lang]) {
// 使用 Prism 高亮代码
const highlightedCode = Prism.highlight(str, Prism.languages[lang], lang);
// 添加代码区域
container += `<pre class="language-${lang}"><code class="language-${lang}">${highlightedCode}</code></pre>`;
} else {
// 普通代码块
container += `<pre class="language-none"><code>${escapeHtml(str)}</code></pre>`;
}
container += `</div>`;
return container;
} else {
return Prism.highlight(str, Prism.languages[lang], lang);
}
}
}
// 全局复制函数
(window as any).copyCode = function (button: HTMLElement) {
const codeBlock = button.closest('.openmcp-code-block');
if (!codeBlock) return;
const codeElement = codeBlock.querySelector('code');
const code = codeElement?.textContent || '';
navigator.clipboard.writeText(code).then(() => {
// 支持 nodejs 下运行
const thisWindow = window as any;
if (!thisWindow || !thisWindow.navigator || !thisWindow.navigator.clipboard) {
return;
}
window.navigator.clipboard.writeText(code).then(() => {
const originalText = button.textContent;
button.textContent = '已复制';
setTimeout(() => {

View File

@ -3,7 +3,7 @@ import MarkdownKatex from './markdown-katex';
import MarkdownHighlight from './markdown-highlight';
const md = new MarkdownIt({
highlight: MarkdownHighlight,
highlight: MarkdownHighlight({ needTools: true }),
});
md.use(MarkdownKatex, {
@ -18,6 +18,41 @@ export const markdownToHtml = (markdown: string) => {
return md.render(markdown);
};
const pureHighLightMd = new MarkdownIt({
highlight: MarkdownHighlight({ needTools: false }),
});
export const copyToClipboard = (text: string) => {
// 支持 nodejs 下运行
const thisWindow = window as any;
if (!thisWindow || !thisWindow.navigator || !thisWindow.navigator.clipboard) {
return;
}
return navigator.clipboard.writeText(text);
};
const tryParseJson = (text: string) => {
try {
return JSON.parse(text);
} catch (error) {
return text;
}
}
const prettifyObj = (obj: object | string) => {
const rawObj = typeof obj === 'string' ? tryParseJson(obj) : obj;
return JSON.stringify(rawObj, null, 2);
}
export const renderJson = (obj: object | string | undefined) => {
if (!obj) {
return '<span>Invalid JSON</span>';
}
const jsonString = prettifyObj(obj);
const md = "```json\n" + jsonString + "\n```";
const html = pureHighLightMd.render(md);
return html;
}

View File

@ -36,7 +36,6 @@
max-height: inherit;
height: inherit;
display: block;
overflow: auto;
background-color: unset !important;
}
@ -251,7 +250,6 @@
max-height: inherit;
height: inherit;
display: block;
overflow: auto;
background-color: unset !important;
}

View File

@ -1,13 +1,13 @@
<template>
<div class="message-role">Agent</div>
<div class="message-text">
<div v-if="message.content" v-html="markdownToHtml(props.message.content)"></div>
<div v-if="message.content" v-html="markdownToHtml(messageContent)"></div>
</div>
<MessageMeta :message="props.message" />
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
import { computed, defineProps } from 'vue';
import { markdownToHtml } from '@/components/main-panel/chat/markdown/markdown';
import MessageMeta from './message-meta.vue';
@ -23,6 +23,17 @@ const props = defineProps({
}
});
const messageContent = computed(() => {
if (typeof props.message.content === 'undefined') {
return 'undefined';
}
if (typeof props.message.content === 'object') {
return JSON.stringify(props.message.content, null, 2);
}
return props.message.content.toString();
});
</script>
<style>

View File

@ -1,5 +1,5 @@
<template>
<div class="message-avatar">
<div class="message-avatar streaming-box">
<span class="iconfont icon-chat"></span>
</div>
<div class="message-content">
@ -11,7 +11,7 @@
</span>
</span>
</div>
<div class="message-text">
<div class="message-text streaming-box">
<span v-html="waitingMarkdownToHtml(streamingContent)"></span>
</div>
</div>

View File

@ -1,5 +1,5 @@
<template>
<el-scrollbar width="100%">
<el-scrollbar width="100%" max-height="300px">
<div v-if="props.item.type === 'text'" class="tool-text">
{{ props.item.text }}
</div>

View File

@ -1,14 +1,17 @@
<template>
<div class="message-role">
<span class="message-reminder" v-if="!props.message.toolResult">
<span class="message-reminder" v-if="callingTools">
Agent 正在使用工具
<span class="tool-loading iconfont icon-double-loading">
</span>
</span>
</div>
<div class="message-text tool_calls" :class="[currentMessageLevel]">
<!-- 工具的消息 -->
<div v-if="props.message.content" v-html="markdownToHtml(props.message.content)"></div>
<!-- 工具的调用 -->
<el-collapse v-model="activeNames" v-if="props.message.tool_calls">
<el-collapse-item name="tool">
<template #title>
@ -26,86 +29,115 @@
</div>
</template>
<div>
<div class="tool-arguments">
<div class="inner">
<div v-html="jsonResultToHtml(props.message.tool_calls[0].function.arguments)"></div>
<div v-for="(toolResult, toolIndex) in props.message.toolResults" :key="toolIndex"
class="toolcall-item">
<div class="tool-calls" v-if="toolIndex > 0">
<div class="tool-call-header">
<span class="tool-name">
<span class="iconfont icon-tool"></span>
{{ props.message.tool_calls[toolIndex].function.name }}
</span>
<el-button size="small" @click="createTest(props.message.tool_calls[toolIndex])">
<span class="iconfont icon-send"></span>
</el-button>
</div>
</div>
<div class="tool-arguments">
<json-render :json="props.message.tool_calls[toolIndex].function.arguments"/>
</div>
<!-- 工具调用结果 -->
<div v-if="props.message.toolResult">
<div v-if="toolResult.length > 0">
<div class="tool-call-header result">
<span class="tool-name">
<span class="tool-name" v-if="isValid(toolResult)">
<span :class="`iconfont icon-info`"></span>
{{ t("response") }}
</span>
<span class="tool-name" v-else>
<span :class="`iconfont icon-${currentMessageLevel}`"></span>
{{ isValid ? '响应': '错误' }}
<el-button v-if="!isValid" size="small"
@click="gotoIssue()"
>
反馈
{{ isValid(toolResult) ? t("response") : t('error') }}
<el-button size="small" @click="gotoIssue()">
{{ t('feedback') }}
</el-button>
</span>
<span style="width: 200px;" class="tools-dialog-container" v-if="currentMessageLevel === 'info'">
<span style="width: 200px;" class="tools-dialog-container"
v-if="currentMessageLevel === 'info'">
<el-switch v-model="props.message.showJson!.value" inline-prompt active-text="JSON"
inactive-text="Text" style="margin-left: 10px; width: 200px;"
:inactive-action-style="'backgroundColor: var(--sidebar)'" />
</span>
</div>
<div class="tool-result" v-if="isValid">
<div class="tool-result" v-if="isValid(toolResult)">
<!-- 展示 JSON -->
<div v-if="props.message.showJson!.value" class="tool-result-content">
<div class="inner">
<div v-html="toHtml(props.message.toolResult)"></div>
</div>
<json-render :json="props.message.toolResults[toolIndex]"/>
</div>
<!-- 展示富文本 -->
<span v-else>
<div v-for="(item, index) in props.message.toolResult" :key="index"
class="response-item"
>
<ToolcallResultItem
:item="item"
@update:item="value => updateToolCallResultItem(value, index)"
@update:ocr-done="value => collposePanel()"
/>
<div v-for="(item, index) in props.message.toolResults[toolIndex]" :key="index"
class="response-item">
<ToolcallResultItem :item="item"
@update:item="value => updateToolCallResultItem(value, toolIndex, index)"
@update:ocr-done="value => collposePanel()" />
</div>
</span>
</div>
<div v-else class="tool-result">
<div class="tool-result-content"
v-for="(error, index) of collectErrors"
:key="index"
>
<div class="tool-result-content" v-for="(error, index) of collectErrors(toolResult)"
:key="index">
{{ error }}
</div>
</div>
</div>
<div v-else style="width: 90%">
<div class="tool-call-header result">
<span class="tool-name">
<span :class="`iconfont icon-waiting`"></span>
{{ t('waiting-mcp-server') }}
</span>
</div>
<div class="tool-result-content">
<div class="progress">
<el-progress :percentage="100" :format="() => ''" :indeterminate="true" text-inside />
</div>
</div>
</div>
<MessageMeta :message="message" />
<MessageMeta v-if="toolIndex === props.message.toolResults.length - 1" :message="message" />
</div>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup lang="ts">
import { defineProps, ref, watch, PropType, computed, defineEmits } from 'vue';
import { useI18n } from 'vue-i18n';
import MessageMeta from './message-meta.vue';
import { markdownToHtml } from '@/components/main-panel/chat/markdown/markdown';
import { createTest } from '@/views/setting/llm';
import { IRenderMessage, MessageState } from '../chat-box/chat';
import { IToolRenderMessage, MessageState } from '../chat-box/chat';
import { ToolCallContent } from '@/hook/type';
import ToolcallResultItem from './toolcall-result-item.vue';
import JsonRender from '@/components/json-render/index.vue';
const { t } = useI18n();
const props = defineProps({
message: {
type: Object as PropType<IRenderMessage>,
type: Object as PropType<IToolRenderMessage>,
required: true
},
tabId: {
@ -115,20 +147,37 @@ const props = defineProps({
});
const hasOcr = computed(() => {
for (const item of props.message.toolResult || []) {
const metaInfo = item._meta || {};
const { ocr = false } = metaInfo;
if (ocr) {
return true;
if (props.message.role === 'assistant/tool_calls') {
for (const toolResult of props.message.toolResults) {
for (const item of toolResult) {
const metaInfo = item._meta || {};
const { ocr = false } = metaInfo;
if (ocr) {
return true;
}
}
}
}
return false;
});
const activeNames = ref<string[]>(props.message.toolResult ? [''] : ['tool']);
const callingTools = computed(() => {
const emptyToolResult = props.message.toolResults.find(item => item.length === 0);
if (emptyToolResult) {
return true;
}
return false;
});
const activeNames = ref<string[]>(callingTools.value ? ['tool']: []);
watch(
() => props.message.toolResult,
() => props.message,
(value, _) => {
if (hasOcr.value) {
return;
@ -146,34 +195,15 @@ function collposePanel() {
}, 1000);
}
/**
* @description 将工具调用结果转换成 html
* @param toolResult
*/
const toHtml = (toolResult: ToolCallContent[]) => {
const formattedJson = JSON.stringify(toolResult, null, 2);
const html = markdownToHtml('```json\n' + formattedJson + '\n```');
return html;
};
const jsonResultToHtml = (jsonResult: string) => {
try {
const formattedJson = JSON.stringify(JSON.parse(jsonResult), null, 2);
const html = markdownToHtml('```json\n' + formattedJson + '\n```');
return html;
} catch (error) {
const html = markdownToHtml('```json\n' + jsonResult + '\n```');
return html;
}
}
function gotoIssue() {
window.open('https://github.com/LSTM-Kirigaya/openmcp-client/issues', '_blank');
}
const isValid = computed(() => {
function isValid(toolResult: ToolCallContent[]) {
try {
const item = props.message.toolResult![0];
const item = toolResult[0];
if (item.type === 'error') {
return false;
}
@ -181,24 +211,36 @@ const isValid = computed(() => {
} catch {
return false;
}
});
}
const currentMessageLevel = computed(() => {
if (!isValid.value) {
return 'error';
// mcp server
for (const toolResult of props.message.toolResults) {
if (toolResult.length === 0) {
return 'info';
}
if (!isValid(toolResult)) {
return 'error';
}
}
if (props.message.extraInfo.state != MessageState.Success) {
if (props.message.extraInfo.state !== MessageState.Success) {
return 'warning';
}
return 'info';
})
const collectErrors = computed(() => {
return 'info';
});
function collectErrors(toolResult: ToolCallContent[]) {
const errorMessages = [];
try {
const errorResults = props.message.toolResult!.filter(item => item.type === 'error');
const errorResults = toolResult.filter(item => item.type === 'error');
console.log(errorResults);
for (const errorResult of errorResults) {
errorMessages.push(errorResult.text);
}
@ -206,12 +248,12 @@ const collectErrors = computed(() => {
} catch {
return errorMessages;
}
});
}
const emit = defineEmits(['update:tool-result']);
const emits = defineEmits(['update:tool-result']);
function updateToolCallResultItem(value: any, index: number) {
emit('update:tool-result', value, index);
function updateToolCallResultItem(value: any, toolIndex: number, index: number) {
emits('update:tool-result', value, toolIndex, index);
}
</script>
@ -223,6 +265,13 @@ function updateToolCallResultItem(value: any, index: number) {
padding: 3px 10px;
}
.tool-result-content .progress {
border-radius: .5em;
background-color: var(--el-fill-color-light) !important;
padding: 20px 10px;
width: 50%;
}
.message-text.tool_calls.warning {
border: 1px solid var(--el-color-warning);
}
@ -232,7 +281,7 @@ function updateToolCallResultItem(value: any, index: number) {
}
.message-text.tool_calls.warning .tool-result {
background-color: rgba(230, 162, 60, 0.5);
background-color: rgba(230, 162, 60, 0.5);
}
.message-text.tool_calls.error {
@ -244,7 +293,7 @@ function updateToolCallResultItem(value: any, index: number) {
}
.message-text.tool_calls.error .tool-result {
background-color: rgba(245, 108, 108, 0.5);
background-color: rgba(245, 108, 108, 0.5);
}
@ -258,6 +307,9 @@ function updateToolCallResultItem(value: any, index: number) {
padding-bottom: 5px;
}
.toolcall-item .tool-calls {
margin-top: 22px;
}
.tool-call-item {
margin-bottom: 10px;

View File

@ -34,7 +34,7 @@
<script setup lang="ts">
import { defineProps, ref, PropType, inject } from 'vue';
import { tabs } from '../../panel';
import { ChatStorage, IRenderMessage } from '../chat';
import type { ChatStorage, IRenderMessage } from '../chat-box/chat';
import KCuteTextarea from '@/components/k-cute-textarea/index.vue';
import { ElMessage } from 'element-plus';
@ -58,7 +58,7 @@ const tabStorage = tab.storage as ChatStorage;
const isEditing = ref(false);
const userInput = ref('');
const handleSend = inject<(newMessage: string | undefined) => void>('handleSend');
const chatContext = inject('chatContext') as any;
const toggleEdit = () => {
isEditing.value = !isEditing.value;
@ -70,10 +70,12 @@ const toggleEdit = () => {
const handleKeydown = (event: KeyboardEvent) => {
const index = tabStorage.messages.findIndex(msg => msg.extraInfo === props.message.extraInfo);
if (index !== -1 && handleSend) {
console.log(chatContext);
if (index !== -1 && chatContext.handleSend) {
// index index
tabStorage.messages.splice(index);
handleSend(userInput.value);
chatContext.handleSend(userInput.value);
isEditing.value = false;
}

View File

@ -24,7 +24,7 @@
</span>
</template>
<template v-else>
{{ formattedJson }}
<json-render :json="tabStorage.lastPromptGetResponse"/>
</template>
</div>
</el-scrollbar>
@ -36,6 +36,7 @@ import { defineComponent, defineProps, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { tabs } from '../panel';
import { PromptStorage } from './prompts';
import JsonRender from '@/components/json-render/index.vue';
defineComponent({ name: 'prompt-logger' });
const { t } = useI18n();
@ -52,13 +53,6 @@ const tabStorage = tab.storage as PromptStorage;
const showRawJson = ref(false);
const formattedJson = computed(() => {
try {
return JSON.stringify(tabStorage.lastPromptGetResponse, null, 2);
} catch {
return 'Invalid JSON';
}
});
</script>
<style>

View File

@ -16,7 +16,7 @@
<el-switch v-else-if="param.type === 'boolean'" v-model="tabStorage.formData[param.name]" />
</el-form-item>
<el-form-item>
<el-form-item v-if="tabStorage.currentType === 'template'">
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ t('read-resource') }}
</el-button>
@ -24,6 +24,11 @@
{{ t('reset') }}
</el-button>
</el-form-item>
<el-form-item v-else>
<el-button @click="handleSubmit">
{{ t("refresh") }}
</el-button>
</el-form-item>
</el-form>
</div>
</template>
@ -142,9 +147,7 @@ function getUri() {
}
const currentResourceName = props.tabId >= 0 ? tabStorage.currentResourceName : props.currentResourceName;
const targetResource = resourcesManager.resources.find(resources => resources.name === currentResourceName);
return targetResource?.uri;
}
@ -154,9 +157,7 @@ async function handleSubmit() {
const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: uri });
tabStorage.lastResourceReadResponse = msg;
emits('resource-get-response', msg);
}

View File

@ -75,12 +75,18 @@ function reloadResources(option: { first: boolean }) {
}
}
function handleClick(resource: Resources) {
async function handleClick(resource: Resources) {
tabStorage.currentType = 'resource';
tabStorage.currentResourceName = resource.name;
tabStorage.lastResourceReadResponse = undefined;
emits('resource-selected', resource);
//
if (props.tabId >= 0) {
const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: resource.uri });
tabStorage.lastResourceReadResponse = msg;
}
}
let commandCancel: (() => void);

View File

@ -37,7 +37,7 @@
</span>
</template>
<template v-else>
{{ formattedJson }}
<json-render :json="tabStorage.lastResourceReadResponse"/>
</template>
</div>
</el-scrollbar>
@ -50,6 +50,7 @@ import { useI18n } from 'vue-i18n';
import { tabs } from '../panel';
import { ResourceStorage } from './resources';
import { getImageBlobUrlByBase64 } from '@/hook/util';
import JsonRender from '@/components/json-render/index.vue';
defineComponent({ name: 'resource-logger' });
const { t } = useI18n();

View File

@ -26,7 +26,7 @@
<!-- 展示 json -->
<template v-else>
{{ formattedJson }}
<json-render :json="tabStorage.lastToolCallResponse"/>
</template>
</div>
@ -40,7 +40,7 @@ import { defineComponent, defineProps, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { tabs } from '../panel';
import { ToolStorage } from './tools';
import { useMessageBridge } from '@/api/message-bridge';
import JsonRender from '@/components/json-render/index.vue';
defineComponent({ name: 'tool-logger' });
const { t } = useI18n();
@ -57,17 +57,6 @@ const tabStorage = tab.storage as ToolStorage;
const showRawJson = ref(false);
const formattedJson = computed(() => {
try {
if (typeof tabStorage.lastToolCallResponse === 'string') {
return tabStorage.lastToolCallResponse;
}
return JSON.stringify(tabStorage.lastToolCallResponse, null, 2);
} catch {
return 'Invalid JSON';
}
});
</script>
<style>

View File

@ -29,9 +29,6 @@ export function callTool(toolName: string, toolArgs: Record<string, any>) {
resolve(data.msg);
}
}, { once: true });
pinkLog('callTool');
console.log(toolArgs);
bridge.postMessage({
command: 'tools/call',

View File

@ -147,8 +147,8 @@ interface GetColorOption {
export class MacroColor {
private option: ComputedColorOption;
private rootStyles: CSSStyleDeclaration;
private theme: 'light' | 'dark';
private rootStyles?: CSSStyleDeclaration;
private theme: 'light' | 'dark' = 'dark';
public foregroundColor: RgbColor | undefined;
public backgroundColor: RgbColor | undefined;
public foregroundColorString: string;
@ -195,7 +195,7 @@ export class MacroColor {
if (mode === 'svg') {
// svg 模式下,导出的效果和 webview 渲染效果基本一致,直接导出即可
return rootStyles.getPropertyValue(macroName);
return rootStyles?.getPropertyValue(macroName) || '#fff';
}
// pdf 模式需要对黑色主题的几个特殊颜色进行处理,并对所有透明颜色进行混合处理
@ -208,7 +208,7 @@ export class MacroColor {
}
}
const colorString = rootStyles.getPropertyValue(macroName);
const colorString = rootStyles?.getPropertyValue(macroName) || '#fff';
if (!colorString) {
// 如果 macroName 不存在,返回空字符串
return colorString;

View File

@ -18,6 +18,12 @@ export function getThemeColor(): 'light' | 'dark' {
if (themeColor) {
return themeColor;
}
const myDocument = document as any;
if (!myDocument) {
return 'dark';
}
const rootStyles = getComputedStyle(document.documentElement);
const backgroundColorString = rootStyles.getPropertyValue('--background');
const backgroundColor = Color.parseColor(backgroundColorString);

View File

@ -152,5 +152,8 @@
"choose-presetting": "اختر الإعداد المسبق",
"cwd": "دليل التنفيذ",
"mcp-server-timeout": "أطول وقت لاستدعاء أداة MCP",
"return": "عودة"
"return": "عودة",
"error": "خطأ",
"feedback": "تعليقات",
"waiting-mcp-server": "في انتظار استجابة خادم MCP"
}

View File

@ -152,5 +152,8 @@
"choose-presetting": "Voreinstellung auswählen",
"cwd": "Ausführungsverzeichnis",
"mcp-server-timeout": "Maximale Aufrufzeit des MCP-Tools",
"return": "Zurück"
"return": "Zurück",
"error": "Fehler",
"feedback": "Feedback",
"waiting-mcp-server": "Warten auf Antwort vom MCP-Server"
}

View File

@ -152,5 +152,8 @@
"choose-presetting": "Select preset",
"cwd": "Execution directory",
"mcp-server-timeout": "Maximum call time of MCP tool",
"return": "Back"
"return": "Back",
"error": "Error",
"feedback": "Feedback",
"waiting-mcp-server": "Waiting for MCP server response"
}

View File

@ -152,5 +152,8 @@
"choose-presetting": "Sélectionner un préréglage",
"cwd": "Répertoire d'exécution",
"mcp-server-timeout": "Temps d'appel maximum de l'outil MCP",
"return": "Retour"
"return": "Retour",
"error": "Erreur",
"feedback": "Retour",
"waiting-mcp-server": "En attente de la réponse du serveur MCP"
}

View File

@ -152,5 +152,8 @@
"choose-presetting": "プリセットを選択",
"cwd": "実行ディレクトリ",
"mcp-server-timeout": "MCPツールの最大呼び出し時間",
"return": "戻る"
"return": "戻る",
"error": "エラー",
"feedback": "フィードバック",
"waiting-mcp-server": "MCPサーバーの応答を待機中"
}

View File

@ -152,5 +152,8 @@
"choose-presetting": "프리셋 선택",
"cwd": "실행 디렉터리",
"mcp-server-timeout": "MCP 도구 최대 호출 시간",
"return": "돌아가기"
"return": "돌아가기",
"error": "오류",
"feedback": "피드백",
"waiting-mcp-server": "MCP 서버 응답 대기 중"
}

View File

@ -152,5 +152,8 @@
"choose-presetting": "Выбрать预设",
"cwd": "Каталог выполнения",
"mcp-server-timeout": "Максимальное время вызова инструмента MCP",
"return": "Назад"
"return": "Назад",
"error": "Ошибка",
"feedback": "Обратная связь",
"waiting-mcp-server": "Ожидание ответа от сервера MCP"
}

View File

@ -152,5 +152,8 @@
"choose-presetting": "选择预设",
"cwd": "执行目录",
"mcp-server-timeout": "MCP工具最长调用时间",
"return": "返回"
"return": "返回",
"error": "错误",
"feedback": "反馈",
"waiting-mcp-server": "等待 MCP 服务器响应"
}

View File

@ -152,5 +152,8 @@
"choose-presetting": "選擇預設",
"cwd": "執行目錄",
"mcp-server-timeout": "MCP工具最長調用時間",
"return": "返回"
"return": "返回",
"error": "錯誤",
"feedback": "反饋",
"waiting-mcp-server": "等待MCP伺服器響應"
}

View File

@ -39,7 +39,9 @@ const router = createRouter({
router.beforeEach((to, from, next) => {
if (to.meta.title) {
const myDocument = document as any;
if (to.meta.title && myDocument) {
document.title = `OpenMCP | ${to.meta.title}`;
}
next();

View File

@ -6,7 +6,7 @@
</span>
<p>
OpenMCP Client 0.0.7 OpenMCP@<a href="https://www.zhihu.com/people/can-meng-zhong-de-che-xian">锦恢</a> 开发
OpenMCP Client 0.0.8 OpenMCP@<a href="https://www.zhihu.com/people/can-meng-zhong-de-che-xian">锦恢</a> 开发
</p>
<p>

View File

@ -1,8 +1,7 @@
import { useMessageBridge } from '@/api/message-bridge';
import { reactive, ref } from 'vue';
import { pinkLog } from '../setting/util';
import { arrowMiddleware, ElMessage } from 'element-plus';
import { ILaunchSigature } from '@/hook/type';
import { ElMessage } from 'element-plus';
import { OpenMcpSupportPlatform } from '@/api/platform';
export const connectionMethods = reactive({
@ -172,10 +171,7 @@ async function launchStdio(namespace: string) {
message: msg
});
ElMessage({
type: 'error',
message: msg
});
ElMessage.error(msg);
}
}
@ -226,10 +222,7 @@ async function launchSSE(namespace: string) {
message: msg
});
ElMessage({
type: 'error',
message: msg
});
ElMessage.error(msg);
}
}

View File

@ -2,22 +2,26 @@ import { ChatStorage } from '@/components/main-panel/chat/chat-box/chat';
import { TaskLoop } from '@/components/main-panel/chat/core/task-loop';
import { llmManager } from './llm';
import { reactive, ref } from 'vue';
import { makeUsageStatistic } from '@/components/main-panel/chat/core/usage';
export const llmSettingRef = ref<any>(null);
export const simpleTestResult = reactive<{
done: boolean,
start: boolean,
error: any
error: any,
tps: string | number | undefined
}>({
done: false,
start: false,
error: '',
tps: undefined
});
export function makeSimpleTalk() {
export async function makeSimpleTalk() {
simpleTestResult.done = false;
simpleTestResult.start = true;
simpleTestResult.tps = undefined;
// 使用最简单的 hello 来测试
const testMessage = 'hello';
@ -37,19 +41,36 @@ export function makeSimpleTalk() {
contextLength: 5
}
};
loop.registerOnDone(() => {
console.log('done');
loop.setMaxEpochs(1);
loop.registerOnDone(() => {
simpleTestResult.error = '';
simpleTestResult.done = true;
simpleTestResult.start = false;
});
loop.registerOnError(error => {
console.log(error);
simpleTestResult.error = error;
const errorReason = error.msg;
const errorText = JSON.stringify(errorReason);
simpleTestResult.error = errorText;
simpleTestResult.start = false;
});
loop.start(chatStorage, testMessage);
const startTime = performance.now();
await loop.start(chatStorage, testMessage);
const costTime = (performance.now() - startTime!) / 1000;
const message = chatStorage.messages.at(-1);
console.log(chatStorage.messages);
if (message?.extraInfo) {
const usage = message.extraInfo.usage;
if (usage?.prompt_tokens && usage.completion_tokens) {
const total = usage?.prompt_tokens + usage?.completion_tokens;
simpleTestResult.tps = (total / costTime).toFixed(2);
}
}
}

View File

@ -38,7 +38,7 @@
</div>
</div>
<!-- 根据不同模型展示不同的接入点 -->
<!-- TODO: 根据不同模型展示不同的接入点 -->
<div v-if="false">
</div>
@ -80,7 +80,7 @@
<ConnectTest />
<!-- 当前页面的聊天框 -->
<el-dialog v-model="dialogVisible" width="50%" style="min-width: 500px; max-width: 800px;padding: 20px;">
<el-dialog v-model="dialogVisible" width="50%" class="api-man-dialog">
<br>
@ -286,6 +286,17 @@ function handleCommand(command: {type: string, index: number}) {
</script>
<style>
.api-man-dialog {
min-width: 500px;
max-width: 800px;
padding: 20px;
}
.api-man-dialog .el-tag {
background-color: var(--main-light-color) !important;
}
.setting-save-container {
margin: 5px;
}

View File

@ -26,6 +26,11 @@ export function onGeneralColorChange(colorString: string) {
return;
}
const { r, g, b } = color;
const myDocument = document as any;
if (!myDocument) {
return;
}
document.documentElement.style.setProperty(
'--main-color', `rgb(${r}, ${g}, ${b})`);

View File

@ -2,22 +2,25 @@
<div class="connect-test" v-if="simpleTestResult.done || simpleTestResult.error">
<div class="test-result">
<div class="result-item" v-if="simpleTestResult.done">
<span class="iconfont icon-success"></span>
<span>{{ "✅ okey dockey :D" }}</span>
<span class="iconfont icon-dui"></span>
<span>{{ " okey dockey :D" }}</span>
<span v-if="simpleTestResult.tps" class="tps">{{ simpleTestResult.tps }} token/s</span>
<span v-else class="tps">{{ t("server-not-support-statistic") }}</span>
</div>
<div class="result-item error" v-if="simpleTestResult.error">
<span class="iconfont icon-error"></span>
<span>{{ ' ' + simpleTestResult.error }}</span>
<span class="iconfont icon-cuo"></span>
<span>{{ ' ' + simpleTestResult.error }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { simpleTestResult } from './api';
defineComponent({ name: 'connect-test' });
const { t } = useI18n();
</script>
<style scoped>
@ -43,6 +46,14 @@ defineComponent({ name: 'connect-test' });
border-radius: 4px;
}
.connect-test .tps {
margin-left: 5px;
color: var(--foreground);
background-color: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
}
.result-item.error {
color: var(--el-color-danger);
}

View File

@ -13,7 +13,20 @@ export class LlmController {
};
}
await streamingChatCompletion(data, webview);
try {
await streamingChatCompletion(data, webview);
} catch (error) {
console.log('error' + error);
webview.postMessage({
command: 'llm/chat/completions/error',
data: {
msg: error
}
});
}
return {
code: -1,

View File

@ -57,9 +57,6 @@ export async function streamingChatCompletion(
});
break;
}
console.log(chunk);
if (chunk.choices) {
const chunkResult = {

View File

@ -31,6 +31,7 @@ export interface McpOptions {
// SSE 特定选项
url?: string;
cwd?: string;
env?: Record<string, string>;
// 通用客户端选项
clientName?: string;
clientVersion?: string;

View File

@ -42,7 +42,8 @@ export class McpClient {
command: this.options.command || '',
args: this.options.args || [],
cwd: this.options.cwd || process.cwd(),
stderr: 'pipe'
stderr: 'pipe',
env: this.options.env,
});
break;
@ -119,8 +120,8 @@ export class McpClient {
// 调用工具
public async callTool(options: { name: string; arguments: Record<string, any>, callToolOption?: any }) {
const { callToolOption, ...methodArgs } = options;
console.log('callToolOption', callToolOption);
return await this.client.callTool(methodArgs, undefined, callToolOption);
}
}

View File

@ -0,0 +1,61 @@
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const webpack = require('webpack');
module.exports = {
mode: 'development', // 设置为 development 模式
devtool: 'source-map', // 生成 source map 以便调试
entry: './renderer/src/components/main-panel/chat/core/task-loop.ts',
output: {
path: path.resolve(__dirname, '../openmcp-sdk'),
filename: 'task-loop.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: {
minimize: false, // 禁用代码压缩
minimizer: [
new TerserPlugin({
extractComments: false, // 禁用提取许可证文件
}),
],
},
plugins: [
new webpack.DefinePlugin({
window: {
nodejs: true,
navigator: {
userAgent: 2
},
performance: {
now: () => Date.now()
}
}
}),
],
externals: {
vue: 'vue', // 不打包 vue 库
'element-plus': './tool.js'
},
};