Compare commits

..

No commits in common. "d9928ecc5d5ae0a036e519d850a8320a9ada0631" and "af3b2cbee50561729c5c05526d23ec2cf95711b0" have entirely different histories.

58 changed files with 744 additions and 6431 deletions

View File

@ -28,4 +28,3 @@ software/**
.github
webpack
.openmcp
.vscode

View File

@ -1,13 +1,5 @@
# Change Log
## [main] 0.1.9
- 增加 mook 功能可以利用随机种子或者AI生成来自动化填充测试 tool 的表单数据
- 增加工具自检功能openmcp 的 tool 下可以点击「工具模块」 右侧的 「工具自检」进入自检模式,该模式下,用户可以自己定义工具执行的拓扑顺序,然后一次性进行自动检测。
- 修复 issue #44: 完成链接跳转的平台适配
- 修复 issue #36: 完成非文件夹打开下的成功启动
- 修复 issue #45: 数组类型参数不支持
- 修复多行对话粘贴进入对话框样式异常的问题
## [main] 0.1.8
- 增加 STDIO 下的热更新,现在用户修改 mcp 代码openmcp 会自动完成一切相关功能的热更新,无需用户手动重启。
- 完成 mcpconfig.json 的导出功能,导出的 配置文件 可以通过 openmcp-sdk 框架完成低代码 agent 部署;也可以直接载入 Claude Desktop 等等 MCP 客户端中,实现 MCP 的快速部署和使用。

View File

@ -15,6 +15,5 @@
"join-project": "Participate in the project",
"comment-plugin": "Comment Plugin",
"preset-env-sync": "Preset environment variables synchronized successfully",
"preset-env-sync.fail": "Failed to sync preset environment variables",
"error.notOpenWorkspace": "No workspace is currently open in VSCode. Please open a workspace (e.g., open a folder) first."
"preset-env-sync.fail": "Failed to sync preset environment variables"
}

View File

@ -15,7 +15,5 @@
"join-project": "プロジェクトに参加する",
"comment-plugin": "コメントプラグイン",
"preset-env-sync": "プリセット環境変数の同期が完了しました",
"preset-env-sync.fail": "プリセット環境変数の同期に失敗しました",
"error.notOpenWorkspace": "現在、VSCode でワークスペースが開かれていません。まずワークスペース(例:フォルダーを開く)を開いてください。"
"preset-env-sync.fail": "プリセット環境変数の同期に失敗しました"
}

View File

@ -15,6 +15,5 @@
"join-project": "参与项目",
"comment-plugin": "评论插件",
"preset-env-sync": "预设环境变量同步完成",
"preset-env-sync.fail": "预设环境变量同步失败",
"error.notOpenWorkspace": "当前VScode没有打开工作区请先打开工作区例如打开文件夹"
"preset-env-sync.fail": "预设环境变量同步失败"
}

863
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
"name": "openmcp",
"displayName": "OpenMCP",
"description": "An all in one MCP Client/TestTool",
"version": "0.1.9",
"version": "0.1.8",
"publisher": "kirigaya",
"private": true,
"author": {
@ -224,7 +224,7 @@
"setup": "yarn install && yarn prepare:ocr",
"serve": "turbo serve",
"build": "turbo build && tsc -p ./ && node esbuild.config.js",
"build:plugin": "yarn build && tsc && vsce package --allow-package-all-secrets",
"build:plugin": "yarn build && tsc && vsce package",
"vscode:prepublish": "node esbuild.config.js",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",

View File

@ -19,14 +19,10 @@
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@faker-js/faker": "^9.8.0",
"chalk": "^5.4.1",
"codemirror": "^6.0.1",
"core-js": "^3.8.3",
"d3": "^7.9.0",
"element-plus": "^2.9.9",
"elkjs": "^0.10.0",
"json-schema-faker": "^0.5.9",
"katex": "^0.16.21",
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",
@ -49,7 +45,6 @@
"@originjs/vite-plugin-commonjs": "^1.0.3",
"@rollup/pluginutils": "^5.1.4",
"@tsconfig/node22": "^22.0.1",
"@types/d3": "^7.4.3",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.14.0",
"@types/prismjs": "^1.26.5",

View File

@ -3,15 +3,6 @@
--main-color: #CB81DA;
--main-dark-color: #2D323B;
--main-light-color: rgba(203, 129, 218, 0.7);
--main-light-color-90: rgba(203, 129, 218, 0.9);
--main-light-color-80: rgba(203, 129, 218, 0.8);
--main-light-color-70: rgba(203, 129, 218, 0.7);
--main-light-color-60: rgba(203, 129, 218, 0.6);
--main-light-color-50: rgba(203, 129, 218, 0.5);
--main-light-color-40: rgba(203, 129, 218, 0.4);
--main-light-color-30: rgba(203, 129, 218, 0.3);
--main-light-color-20: rgba(203, 129, 218, 0.2);
--main-light-color-10: rgba(203, 129, 218, 0.1);
--sidebar-width: 330px;
--right-nav-width: 50px;
--time-scale-height: 30px;

View File

@ -163,26 +163,24 @@ function getDefaultValue(property: any): string {
}
}
watch(
() => props.modelValue,
(newVal) => {
//
const currentContent = editorView.value?.state.doc.toString() ?? '';
const newContent = JSON.stringify(newVal ?? {}, null, 2);
if (currentContent !== newContent && editorView.value) {
editorView.value.dispatch({
changes: {
from: 0,
to: editorView.value.state.doc.length,
insert: newContent
}
});
}
},
{ deep: true }
);
// watch
// watch(
// () => props.modelValue,
// (newVal) => {
// const currentParsed = tryParse(inputValue.value)
// if (!isDeepEqual(currentParsed, newVal)) {
// const newContent = JSON.stringify(newVal, null, 2)
// editorView.value?.dispatch({
// changes: {
// from: 0,
// to: editorView.value.state.doc.length,
// insert: newContent
// }
// })
// }
// },
// { deep: true }
// )
// JSON
const tryParse = (value: string): any => {

View File

@ -58,7 +58,7 @@ export interface EnableToolItem {
}
export interface ChatSetting {
modelIndex?: number
modelIndex: number
systemPrompt: string
enableTools: EnableToolItem[]
temperature: number

View File

@ -56,7 +56,6 @@ import { llmManager, llms } from '@/views/setting/llm';
import { mcpClientAdapter } from '@/views/connect/core';
import { ElMessage } from 'element-plus';
import { useMessageBridge } from '@/api/message-bridge';
import { gotoWebsite } from '@/hook/util';
const { t, locale } = useI18n();
@ -167,11 +166,11 @@ const exportCode = async () => {
const gotoHowtoUse = () => {
if (locale.value === 'zh') {
gotoWebsite('https://kirigaya.cn/openmcp/zh/sdk-tutorial/#%E4%BD%BF%E7%94%A8');
window.open('https://kirigaya.cn/openmcp/zh/sdk-tutorial/#%E4%BD%BF%E7%94%A8');
} else if (locale.value === 'ja') {
gotoWebsite('https://kirigaya.cn/openmcp/ja/sdk-tutorial/#%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95');
window.open('https://kirigaya.cn/openmcp/ja/sdk-tutorial/#%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95');
} else {
gotoWebsite('https://kirigaya.cn/openmcp/sdk-tutorial/#usage');
window.open('https://kirigaya.cn/openmcp/sdk-tutorial/#usage');
}
}

View File

@ -64,15 +64,4 @@ const onRadioGroupChange = () => {
</script>
<style>
.setting-button:hover {
background: var(--main-light-color, #f0f8ff);
box-shadow: 0 2px 8px 0 rgba(64,158,255,0.08);
border-color: var(--el-color-primary-light-7, #c6e2ff);
}
.setting-button:active {
transform: scale(0.95);
}
</style>
<style></style>

View File

@ -162,15 +162,16 @@ provide('tabStorage', tabStorage);
color: var(--el-text-color-secondary);
}
.el-switch__core {
.tools-dialog-container .el-switch__core {
border: 1px solid var(--main-color) !important;
}
.el-switch .el-switch__action {
.tools-dialog-container .el-switch .el-switch__action {
background-color: var(--main-color);
}
.el-switch.is-checked .el-switch__action {
.tools-dialog-container .el-switch.is-checked .el-switch__action {
background-color: var(--sidebar);
}

View File

@ -234,7 +234,6 @@ function handleCompositionEnd() {
.rich-editor {
min-height: 100px;
outline: none;
white-space: pre-wrap;
}
.rich-editor:empty::before {

View File

@ -48,7 +48,6 @@ export class TaskLoop {
private bridge: MessageBridge;
private streamingContent: Ref<string>;
private streamingToolCalls: Ref<ToolCall[]>;
private aborted = false;
private currentChatId = '';
private onError: (error: IErrorMssage) => void = (msg) => { };
@ -319,7 +318,6 @@ export class TaskLoop {
});
this.streamingContent.value = '';
this.streamingToolCalls.value = [];
this.aborted = true;
}
/**
@ -547,7 +545,6 @@ export class TaskLoop {
maxEpochs = 50,
verbose = 0
} = this.taskOptions || {};
this.aborted = false;
for (let i = 0; i < maxEpochs; ++i) {
@ -573,12 +570,6 @@ export class TaskLoop {
// 发送请求
const doConverationResult = await this.doConversation(chatData, toolcallIndexAdapter);
// 如果在调用过程中出发了 abort则直接中断
if (this.aborted) {
this.aborted = false;
break;
}
// 如果存在需要调度的工具
if (this.streamingToolCalls.value.length > 0) {
@ -606,19 +597,8 @@ export class TaskLoop {
// ready to call tools
toolCall = this.consumeToolCalls(toolCall);
if (this.aborted) {
this.aborted = false;
break;
}
let toolCallResult = await handleToolCalls(toolCall);
if (this.aborted) {
this.aborted = false;
break;
}
// hook : finish call tools
toolCallResult = this.consumeToolCalleds(toolCallResult);
@ -676,11 +656,6 @@ export class TaskLoop {
}
}
if (this.aborted) {
this.aborted = false;
break;
}
} else if (this.streamingContent.value) {
tabStorage.messages.push({
role: 'assistant',

View File

@ -318,9 +318,6 @@ function updateToolCallResultItem(value: any, toolIndex: number, index: number)
.tool-call-header {
display: flex;
align-items: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.tool-call-header.result {

View File

@ -3,18 +3,29 @@
<div class="tabs-container">
<el-scrollbar>
<div class="scroll-tabs-container">
<span class="tab" v-for="(tab, index) of tabs.content" :key="tab.id"
:class="{ 'active-tab': tabs.activeIndex === index }" @click="setActiveTab(index)">
<span
class="tab"
v-for="(tab, index) of tabs.content"
:key="tab.id"
:class="{ 'active-tab': tabs.activeIndex === index }"
@click="setActiveTab(index)"
>
<span>
<span :class="`iconfont ${tab.icon}`"></span>
<span class="tab-name">{{ tab.name }}</span>
</span>
<span class="iconfont icon-close" @click.stop="closeTab(index)"></span>
<span
class="iconfont icon-close"
@click.stop="closeTab(index)"
></span>
</span>
</div>
</el-scrollbar>
<span class="add-button iconfont icon-add" @click="pageAddNewTab">
<span
class="add-button iconfont icon-add"
@click="pageAddNewTab"
>
</span>
</div>
@ -110,12 +121,7 @@ function setActiveTab(index: number) {
position: relative;
}
.tabs-container .tab:active {
transform: scale(0.95);
transition: var(--animation-3s);
}
.tabs-container .tab>span:first-child {
.tabs-container .tab > span:first-child {
display: flex;
align-items: center;
}

View File

@ -4,7 +4,7 @@
<div class="left">
<h2>
<span class="iconfont icon-chat"></span>
{{ t('prompt-module') }}
提示词模块
</h2>
<PromptTemplates :tab-id="props.tabId"></PromptTemplates>
@ -24,9 +24,6 @@ import { defineProps } from 'vue';
import PromptTemplates from './prompt-templates.vue';
import PromptReader from './prompt-reader.vue';
import PromptLogger from './prompt-logger.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
tabId: {

View File

@ -86,7 +86,7 @@ const formRules = computed<FormRules>(() => {
currentPrompt.value?.arguments.forEach(param => {
rules[param.name] = [
{
message: `${param.name} ` + t('is-required'),
message: `${param.name} 是必填字段`,
trigger: 'blur'
}
]

View File

@ -8,27 +8,23 @@
<span>prompts/list</span>
<span @click.stop="reloadPrompts(client, { first: false })" class="iconfont icon-restart"></span>
</h3>
</template>
<!-- body -->
<div class="prompt-template-container-scrollbar">
<el-scrollbar height="fit-content" v-if="(client.promptTemplates?.size || 0) > 0">
<el-scrollbar height="500px">
<div class="prompt-template-container">
<div class="item"
:class="{ 'active': props.tabId >= 0 && tabStorage.currentPromptName === template.name }"
v-for="template of client.promptTemplates?.values()" :key="template.name"
@click="handleClick(template)">
<span class="prompt-title">{{ template.name }}</span>
<span class="prompt-description">{{ template.description || '' }}</span>
<span>{{ template.name }}</span>
<span>{{ template.description || '' }}</span>
</div>
</div>
</el-scrollbar>
<div v-else style="padding: 10px;">
<div class="empty-description">
<span class="iconfont icon-empty" style="font-size: 22px; opacity: 0.4; margin-right: 6px;"></span>
<span style="opacity: 0.6;">No prompts found.</span>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
@ -130,8 +126,8 @@ onMounted(async () => {
user-select: none;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: center;
justify-content: space-between;
transition: var(--animation-3s);
}
@ -140,40 +136,24 @@ onMounted(async () => {
transition: var(--animation-3s);
}
.prompt-template-container>.item:active {
transform: scale(0.95);
transition: var(--animation-3s);
}
.prompt-template-container>.item.active {
background-color: var(--main-light-color);
transition: var(--animation-3s);
}
.prompt-title {
font-weight: bold;
max-width: 250px;
.prompt-template-container>.item>span:first-child {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prompt-description {
.prompt-template-container>.item>span:last-child {
opacity: 0.6;
font-size: 12.5px;
max-width: 250px;
overflow: visible;
text-overflow: unset;
white-space: normal;
word-break: break-all;
}
.empty-description {
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder, #bbb);
font-size: 15px;
min-height: 40px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -115,7 +115,7 @@ const formRules = computed<FormRules>(() => {
currentResource.value?.params.forEach(param => {
rules[param] = [
{
message: `${param} ` + t('is-required'),
message: `${param} 是必填字段`,
trigger: 'blur'
}
]

View File

@ -12,22 +12,19 @@
<!-- body -->
<div class="resource-template-container-scrollbar">
<el-scrollbar height="fit-content" v-if="(client.resourceTemplates?.size || 0) > 0">
<el-scrollbar height="500px" v-if="(client.resourceTemplates?.size || 0) > 0">
<div class="resource-template-container">
<div class="item"
:class="{ 'active': props.tabId >= 0 && tabStorage.currentType === 'template' && tabStorage.currentResourceName === template.name }"
v-for="template of client.resourceTemplates?.values()" :key="template.name"
@click="handleClick(template)">
<span class="resource-title">{{ template.name }}</span>
<span class="resource-description">{{ template.description || '' }}</span>
<span>{{ template.name }}</span>
<span>{{ template.description || '' }}</span>
</div>
</div>
</el-scrollbar>
<div v-else style="padding: 10px;">
<div class="empty-description">
<span class="iconfont icon-empty" style="font-size: 22px; opacity: 0.4; margin-right: 6px;"></span>
<span style="opacity: 0.6;">No resource templates found.</span>
</div>
empty
</div>
</div>
</el-collapse-item>
@ -140,57 +137,41 @@ h3.resource-template .iconfont.icon-restart:hover {
width: 175px;
}
.resource-template-container > .item {
.resource-template-container>.item {
margin: 3px;
padding: 5px 10px;
border-radius: .3em;
user-select: none;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: center;
justify-content: space-between;
transition: var(--animation-3s);
}
.resource-template-container > .item:hover {
.resource-template-container>.item:hover {
background-color: var(--main-light-color);
transition: var(--animation-3s);
}
.resource-template-container > .item:active {
transform: scale(0.95);
transition: var(--animation-3s);
}
.resource-template-container > .item.active {
.resource-template-container>.item.active {
background-color: var(--main-light-color);
transition: var(--animation-3s);
}
.resource-title {
font-weight: bold;
max-width: 250px;
.resource-template-container>.item>span:first-child {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-description {
.resource-template-container>.item>span:last-child {
opacity: 0.6;
font-size: 12.5px;
max-width: 250px;
overflow: visible;
text-overflow: unset;
white-space: normal;
word-break: break-all;
}
.empty-description {
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder, #bbb);
font-size: 15px;
min-height: 40px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -12,23 +12,17 @@
<!-- body -->
<div class="resource-template-container-scrollbar">
<el-scrollbar height="fit-content" v-if="(client.resources?.size || 0) > 0">
<el-scrollbar height="500px">
<div class="resource-template-container">
<div class="item"
:class="{ 'active': props.tabId >= 0 && tabStorage.currentType === 'resource' && tabStorage.currentResourceName === resource.name }"
v-for="resource of client.resources?.values()" :key="resource.uri"
@click="handleClick(resource)">
<span class="resource-title">{{ resource.name }}</span>
<span class="resource-description">{{ resource.mimeType }}</span>
<span>{{ resource.name }}</span>
<span>{{ resource.mimeType }}</span>
</div>
</div>
</el-scrollbar>
<div v-else style="padding: 10px;">
<div class="empty-description">
<span class="iconfont icon-empty" style="font-size: 22px; opacity: 0.4; margin-right: 6px;"></span>
<span style="opacity: 0.6;">No resources found.</span>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
@ -145,58 +139,41 @@ h3.resource-template .iconfont.icon-restart:hover {
width: 175px;
}
.resource-template-container > .item {
.resource-template-container>.item {
margin: 3px;
padding: 5px 10px;
border-radius: .3em;
user-select: none;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: center;
justify-content: space-between;
transition: var(--animation-3s);
}
.resource-template-container > .item:hover {
.resource-template-container>.item:hover {
background-color: var(--main-light-color);
transition: var(--animation-3s);
}
.resource-template-container > .item.active {
.resource-template-container>.item.active {
background-color: var(--main-light-color);
transition: var(--animation-3s);
}
.resource-template-container > .item:active {
transform: scale(0.95);
transition: var(--animation-3s);
}
.resource-title {
font-weight: bold;
max-width: 250px;
.resource-template-container>.item>span:first-child {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-description {
.resource-template-container>.item>span:last-child {
opacity: 0.6;
font-size: 12.5px;
max-width: 250px;
/* Remove ellipsis and allow full text wrap */
overflow: visible;
text-overflow: unset;
white-space: normal;
word-break: break-all;
}
.empty-description {
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder, #bbb);
font-size: 15px;
min-height: 40px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -1,160 +0,0 @@
<template>
<div class="diagram-item-record" v-if="props.dataView && props.dataView.tool">
<div class="item-header">
<span class="item-title">{{ props.dataView.tool.name }}</span>
<span class="item-status" :class="props.dataView.status">{{ props.dataView.status }}</span>
</div>
<div class="item-desc">{{ props.dataView.tool.description }}</div>
<div v-if="props.dataView.function !== undefined" class="item-result">
<div class="item-label">Function</div>
<div class="item-json">{{ props.dataView.function.name }}</div>
</div>
<div v-if="props.dataView.function !== undefined" class="item-result">
<span class="item-label">Arguments</span>
<json-render :json="props.dataView.function.arguments" />
</div>
<div v-if="props.dataView.result !== undefined" class="item-result">
<span class="item-label">Result</span>
<template v-if="Array.isArray(props.dataView.result)">
<div v-for="(item, idx) in props.dataView.result" :key="idx" class="result-block"
:class="[props.dataView.status]">
<pre class="item-json"
v-if="typeof item === 'object' && item.text !== undefined">{{ item.text }}</pre>
<pre class="item-json" v-else>{{ formatJson(item) }}</pre>
</div>
</template>
<pre class="item-json"
v-else-if="typeof props.dataView.result === 'string'">{{ props.dataView.result }}</pre>
<pre class="item-json" v-else>{{ formatJson(props.dataView.result) }}</pre>
</div>
</div>
<div v-else class="diagram-item-record">
<div class="item-header">
<span class="item-title">No Tool Selected</span>
</div>
<div class="item-desc">Please select a tool to view its details.</div>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue';
import type { NodeDataView } from './diagram';
import JsonRender from '@/components/json-render/index.vue';
const props = defineProps({
dataView: {
type: Object as PropType<NodeDataView | undefined | null>,
required: true
}
})
function formatJson(obj: any) {
try {
return JSON.stringify(obj, null, 2)
} catch {
return String(obj)
}
}
</script>
<style scoped>
.diagram-item-record {
padding: 14px 18px;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
font-size: 15px;
max-width: 1000px;
word-break: break-all;
}
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.item-title {
font-weight: bold;
font-size: 17px;
color: var(--main-color, #409EFF);
}
.item-status {
font-size: 13px;
padding: 2px 10px;
border-radius: 12px;
margin-left: 8px;
text-transform: capitalize;
}
.item-status.running {
color: #2196f3;
}
.item-status.success {
color: #43a047;
}
.item-status.error {
color: #e53935;
}
.item-status.waiting {
color: #aaa;
}
.item-status.default {
color: #888;
}
.item-desc {
margin-bottom: 8px;
opacity: 0.8;
font-size: 14px;
}
.item-label {
font-weight: 500;
margin-right: 4px;
color: var(--main-color, #409EFF);
}
.item-json {
border-radius: 4px;
padding: 6px 10px;
font-size: 13px;
font-family: var(--code-font-family, monospace);
margin: 2px 0 8px 0;
white-space: pre-wrap;
word-break: break-all;
overflow-x: auto;
max-width: 100%;
box-sizing: border-box;
}
.item-result {
margin-top: 6px;
}
.result-block {
margin-bottom: 6px;
border-radius: .5em;
margin: 5px 0;
overflow-x: auto;
max-width: 100%;
}
.result-block.error {
background-color: rgba(245, 108, 108, 0.5);
}
.result-block.success {
background-color: rgba(67, 160, 71, 0.5);
}
</style>

View File

@ -1,248 +0,0 @@
import type { ElkNode } from 'elkjs/lib/elk-api';
import { MessageState, TaskLoop } from '../../chat/core/task-loop';
import type { Reactive } from 'vue';
import type { ChatStorage } from '../../chat/chat-box/chat';
import { ElMessage } from 'element-plus';
import type { ToolItem } from '@/hook/type';
import I18n from '@/i18n';
import type { ChatCompletionChunk } from 'openai/resources/index.mjs';
const { t } = I18n.global;
export interface Edge {
id: string;
sources: string[];
targets: string[];
sections?: any; // { startPoint: { x, y }, endPoint: { x,
}
export type Node = ElkNode & {
[key: string]: any;
width: number;
height: number;
id: string;
};
export interface DiagramState {
nodes: Node[];
edges: Edge[];
selectedNodeId: string | null;
dataView: Map<string, NodeDataView>;
[key: string]: any;
}
export interface CanConnectResult {
canConnect: boolean;
reason?: string;
}
export interface NodeDataView {
tool: ToolItem;
status: 'default' | 'running' | 'waiting' | 'success' | 'error';
function?: ChatCompletionChunk.Choice.Delta.ToolCall.Function;
result?: any;
}
export interface DiagramContext {
reset: () => void,
render: () => void,
state?: DiagramState,
setCaption: (value: string) => void
}
/**
* @description
*/
export function invalidConnectionDetector(state: DiagramState, d: Node): CanConnectResult {
const from = state.selectedNodeId;
const to = d.id;
if (!from) {
return { canConnect: false, reason: t('not-select-begin-node') };
}
if (from === to) {
return { canConnect: false, reason: '' };
}
// 建立邻接表
const adjacencyList: Record<string, Set<string>> = {};
state.edges.forEach(edge => {
const src = edge.sources[0];
const tgt = edge.targets[0];
if (!adjacencyList[src]) {
adjacencyList[src] = new Set();
}
adjacencyList[src].add(tgt);
});
// DFS 检测是否存在
function hasPath(current: string, target: string, visited: Set<string>): boolean {
if (current === target) return true;
visited.add(current);
const neighbors = adjacencyList[current] || new Set();
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
if (hasPath(neighbor, target, visited)) {
return true;
}
}
}
return false;
}
if (hasPath(to, from, new Set())) {
return { canConnect: false, reason: t('can-make-loop') };
}
if (hasPath(from, to, new Set())) {
return { canConnect: false, reason: t('this-is-repeat-connection') };
}
return {
canConnect: true
}
}
/**
* @description id数组
* @returns string[][] id数组
*/
export function topoSortParallel(state: DiagramState): string[][] {
// 统计每个节点的入度
const inDegree: Record<string, number> = {};
state.nodes.forEach(node => {
inDegree[node.id] = 0;
});
state.edges.forEach(edge => {
const tgt = edge.targets[0];
if (tgt in inDegree) {
inDegree[tgt]++;
}
});
// 初始化队列收集所有入度为0的节点
const result: string[][] = [];
let queue: string[] = Object.keys(inDegree).filter(id => inDegree[id] === 0);
const visited = new Set<string>();
while (queue.length > 0) {
// 当前层可以并行的节点
result.push([...queue]);
const nextQueue: string[] = [];
for (const id of queue) {
visited.add(id);
// 遍历所有以当前节点为源的边,减少目标节点的入度
state.edges.forEach(edge => {
if (edge.sources[0] === id) {
const tgt = edge.targets[0];
inDegree[tgt]--;
// 如果目标节点入度为0且未访问过加入下一层
if (inDegree[tgt] === 0 && !visited.has(tgt)) {
nextQueue.push(tgt);
}
}
});
}
queue = nextQueue;
}
// 检查是否有环
if (visited.size !== state.nodes.length) {
throw new Error('图中存在环,无法进行拓扑排序');
}
return result;
}
export async function makeNodeTest(
dataView: Reactive<NodeDataView>,
enableXmlWrapper: boolean,
prompt: string | null = null,
context: DiagramContext
) {
if (!dataView.tool.inputSchema) {
return;
}
dataView.status = 'running';
context.render();
try {
const loop = new TaskLoop({ maxEpochs: 1 });
const usePrompt = (prompt || 'please call the tool {tool} to make some test').replace('{tool}', dataView.tool.name);
const chatStorage = {
messages: [],
settings: {
temperature: 0.6,
systemPrompt: '',
enableTools: [{
name: dataView.tool.name,
description: dataView.tool.description,
inputSchema: dataView.tool.inputSchema,
enabled: true
}],
enableWebSearch: false,
contextLength: 5,
enableXmlWrapper,
parallelToolCalls: false
}
} as ChatStorage;
loop.setMaxEpochs(1);
let aiMockJson: any = undefined;
loop.registerOnToolCall(toolCall => {
dataView.function = toolCall.function;
if (toolCall.function?.name === dataView.tool?.name) {
try {
const toolArgs = JSON.parse(toolCall.function?.arguments || '{}');
aiMockJson = toolArgs;
} catch (e) {
// ElMessage.error('AI 生成的 JSON 解析错误');
dataView.status = 'error';
dataView.result = t('ai-gen-error-json');
context.render();
loop.abort();
}
} else {
// ElMessage.error('AI 调用了未知的工具');
dataView.status = 'error';
dataView.result = t('ai-invoke-unknown-tool') + ' ' + toolCall.function?.name;
context.render();
loop.abort();
}
return toolCall;
});
loop.registerOnToolCalled(toolCalled => {
if (toolCalled.state === MessageState.Success) {
dataView.status = 'success';
dataView.result = toolCalled.content;
} else {
dataView.status = 'error';
dataView.result = toolCalled.content;
}
loop.abort();
return toolCalled;
})
loop.registerOnError(error => {
dataView.status = 'error';
dataView.result = error;
context.render();
});
await loop.start(chatStorage, usePrompt);
} finally {
if (dataView.status === 'running') {
dataView.status = 'success';
context.render();
}
}
};

View File

@ -1,654 +0,0 @@
<template>
<div style="display: flex; align-items: center; gap: 16px;">
<div ref="svgContainer" class="diagram-container"></div>
<template v-for="(node, index) in state.nodes" :key="node.id + '-popup'">
<transition name="collapse-from-top" mode="out-in">
<div
v-show="state.hoverNodeId === node.id && state.dataView.get(node.id)?.status !== 'waiting'"
@mouseenter="setHoverItem(node.id)"
@mouseleave="clearHoverItem()"
:style="getNodePopupStyle(node)"
class="node-popup"
>
<el-scrollbar height="100%" width="100%">
<DiagramItemRecord :data-view="state.dataView.get(node.id)"/>
</el-scrollbar>
</div>
</transition>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, reactive, inject } from 'vue';
import * as d3 from 'd3';
import ELK from 'elkjs/lib/elk.bundled.js';
import { mcpClientAdapter } from '@/views/connect/core';
import { invalidConnectionDetector, type Edge, type Node, type NodeDataView } from './diagram';
import { ElMessage } from 'element-plus';
import DiagramItemRecord from './diagram-item-record.vue';
import { useI18n } from 'vue-i18n';
import type { ToolStorage } from '../tools';
import { tabs } from '../../panel';
const { t } = useI18n();
const props = defineProps({
tabId: {
type: Number,
required: true
}
});
const svgContainer = ref<HTMLDivElement | null>(null);
let prevNodes: any[] = [];
let prevEdges: any[] = [];
const state = reactive({
nodes: [] as Node[],
edges: [] as Edge[],
selectedNodeId: null as string | null,
draggingNodeId: null as string | null,
hoverNodeId: null as string | null,
offset: { x: 0, y: 0 },
dataView: new Map<string, NodeDataView>
});
const tab = tabs.content[props.tabId];
const tabStorage = tab.storage as ToolStorage;
const autoDetectDiagram = tabStorage.autoDetectDiagram;
if (autoDetectDiagram) {
// tabStorage.autoDetectDiagram dataView state
autoDetectDiagram.views?.forEach(item => {
state.dataView.set(item.tool.name, {
tool: item.tool,
status: item.status || 'waiting',
result: item.result || null
});
});
} else {
tabStorage.autoDetectDiagram = {
edges: [],
views: []
};
}
console.log(tabStorage.autoDetectDiagram!.views);
console.log(state.dataView);
let cancelHoverHandler: NodeJS.Timeout | undefined = undefined;
const setHoverItem = (id: string) => {
if (cancelHoverHandler) {
clearTimeout(cancelHoverHandler);
}
state.hoverNodeId = id;
}
const clearHoverItem = () => {
cancelHoverHandler = setTimeout(() => {
if (cancelHoverHandler) {
clearTimeout(cancelHoverHandler);
}
if (state.hoverNodeId) {
state.hoverNodeId = null;
}
}, 300);
};
const getAllTools = async () => {
const items = [];
for (const client of mcpClientAdapter.clients) {
const clientTools = await client.getTools();
items.push(...clientTools.values());
}
return items;
};
const recomputeLayout = async () => {
const elk = new ELK();
const elkGraph = {
id: 'root',
layoutOptions: {
'elk.direction': 'DOWN',
'elk.spacing.nodeNode': '40',
'elk.layered.spacing.nodeNodeBetweenLayers': '40'
},
children: state.nodes,
edges: state.edges
};
const layout = await elk.layout(elkGraph) as unknown as Node;
state.nodes.forEach((n, i) => {
const ln = layout.children?.find(c => c.id === n.id);
if (ln) {
n.x = ln.x;
n.y = ln.y;
n.width = ln.width || 200; //
n.height = ln.height || 64; //
}
});
state.edges = layout.edges || [];
// tabStorage
tabStorage.autoDetectDiagram!.edges = state.edges.map(edge => ({
id: edge.id,
sources: edge.sources || [],
targets: edge.targets || []
}));
return layout;
};
const drawDiagram = async () => {
const tools = await getAllTools();
//
const nodes = [] as Node[];
const edges = [] as Edge[];
// edges
const reservedEdges = autoDetectDiagram?.edges;
if (reservedEdges) {
for (const edge of reservedEdges) {
if (edge.sources && edge.targets && edge.sources.length > 0 && edge.targets.length > 0) {
edges.push({
id: edge.id,
sources: edge.sources || [],
targets: edge.targets || [],
});
}
}
} else {
for (let i = 0; i < tools.length - 1; ++i) {
const prev = tools[i];
const next = tools[i + 1];
edges.push({
id: prev.name + '-' + next.name,
sources: [prev.name],
targets: [next.name]
})
}
}
for (const tool of tools) {
nodes.push({
id: tool.name,
width: 200,
height: 64, //
labels: [{ text: tool.name || 'Tool' }]
});
if (!state.dataView.has(tool.name)) {
// dataView
state.dataView.set(tool.name, {
tool,
status: 'waiting'
});
}
}
state.edges = edges;
state.nodes = nodes;
//
await recomputeLayout();
// svg
renderSvg();
};
function renderSvg() {
const prevNodeMap = new Map(prevNodes.map(n => [n.id, n]));
const prevEdgeMap = new Map(prevEdges.map(e => [e.id, e]));
// xx
const xs = state.nodes.map(n => (n.x || 0));
const minX = Math.min(...xs);
const maxX = Math.max(...xs.map((x, i) => x + (state.nodes[i].width || 160)));
const contentWidth = maxX - minX;
const svgWidth = Math.max(contentWidth + 120, 400); // 120
const offsetX = (svgWidth - contentWidth) / 2 - minX;
const height = Math.max(...state.nodes.map(n => (n.y || 0) + (n.height || 48)), 300) + 60;
// svg
let svg = d3.select(svgContainer.value).select('svg');
if (svg.empty()) {
svg = d3
.select(svgContainer.value)
.append('svg')
.attr('width', svgWidth)
.attr('height', height)
.style('user-select', 'none') as any;
} else {
svg.attr('width', svgWidth).attr('height', height);
svg.selectAll('defs').remove();
}
// Arrow marker
svg
.append('defs')
.append('marker')
.attr('id', 'arrow')
.attr('viewBox', '0 0 8 8')
.attr('refX', 6)
.attr('refY', 4)
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 0 0 L 8 4 L 0 8 z')
.attr('fill', 'var(--main-color)');
// 1. / main group
let mainGroup = svg.select('g.main-group');
if (mainGroup.empty()) {
mainGroup = svg.append('g').attr('class', 'main-group') as any;
}
mainGroup
.transition()
.duration(600)
.attr('transform', `translate(${offsetX}, 0)`);
// Draw edges with enter animation
const allSections: { id: string, section: any }[] = [];
(state.edges || []).forEach(edge => {
const sections = edge.sections || [];
sections.forEach((section: any, idx: number) => {
allSections.push({
id: (edge.id || '') + '-' + (section.id || idx),
section
});
});
});
const edgeSelection = mainGroup.selectAll<SVGLineElement, any>('.edge')
.data(allSections, d => d.id);
edgeSelection.exit().remove();
const edgeEnter = edgeSelection.enter()
.append('line')
.attr('class', 'edge')
.attr('x1', d => {
const prev = prevEdgeMap.get(d.id);
return prev && prev.sections && prev.sections[0]
? prev.sections[0].startPoint.x + 30
: d.section.startPoint.x + 30;
})
.attr('y1', d => {
const prev = prevEdgeMap.get(d.id);
return prev && prev.sections && prev.sections[0]
? prev.sections[0].startPoint.y + 30
: d.section.startPoint.y + 30;
})
.attr('x2', d => {
const prev = prevEdgeMap.get(d.id);
return prev && prev.sections && prev.sections[0]
? prev.sections[0].endPoint.x + 30
: d.section.endPoint.x + 30;
})
.attr('y2', d => {
const prev = prevEdgeMap.get(d.id);
return prev && prev.sections && prev.sections[0]
? prev.sections[0].endPoint.y + 30
: d.section.endPoint.y + 30;
})
.attr('stroke', 'var(--main-color)')
.attr('stroke-width', 2.5)
.attr('marker-end', 'url(#arrow)')
.attr('opacity', 0);
edgeEnter
.transition()
.duration(600)
.attr('opacity', 1)
.attr('x1', d => d.section.startPoint.x + 30)
.attr('y1', d => d.section.startPoint.y + 30)
.attr('x2', d => d.section.endPoint.x + 30)
.attr('y2', d => d.section.endPoint.y + 30);
// update + transition opacity
edgeSelection.merge(edgeEnter)
.transition()
.duration(600)
.ease(d3.easeCubicInOut)
.attr('x1', d => d.section.startPoint.x + 30)
.attr('y1', d => d.section.startPoint.y + 30)
.attr('x2', d => d.section.endPoint.x + 30)
.attr('y2', d => d.section.endPoint.y + 30)
.attr('opacity', 1);
// --- ---
const nodeGroup = mainGroup.selectAll<SVGGElement, any>('.node')
.data(state.nodes, d => d.id);
nodeGroup.exit().remove();
// enter
const nodeGroupEnter = nodeGroup.enter()
.append('g')
.attr('class', 'node')
.attr('transform', d => {
const prev = prevNodeMap.get(d.id);
if (prev) {
return `translate(${(prev.x || 0) + 30}, ${(prev.y || 0) + 30})`;
}
return `translate(${(d.x || 0) + 30}, ${(d.y || 0) + 30})`;
})
.style('cursor', 'pointer')
.attr('opacity', 0)
.on('mousedown', null)
.on('mouseup', function (event, d) {
event.stopPropagation();
if (state.selectedNodeId) {
const { canConnect, reason } = invalidConnectionDetector(state, d);
console.log(reason);
if (reason) {
ElMessage.warning(reason);
}
if (canConnect) {
state.edges.push({
id: `e${state.selectedNodeId}_${d.id}_${Date.now()}`,
sources: [state.selectedNodeId],
targets: [d.id]
});
state.selectedNodeId = null;
recomputeLayout().then(renderSvg);
} else {
//
state.selectedNodeId = null;
renderSvg();
}
context.setCaption('');
} else {
state.selectedNodeId = d.id;
renderSvg();
context.setCaption(t('select-node-define-test-tomo'));
}
state.draggingNodeId = null;
})
.on('mouseover', function (event, d) {
setHoverItem(d.id);
d3.select(this).select('rect')
.transition()
.duration(200)
.attr('stroke', 'var(--main-color)')
.attr('stroke-width', 2);
})
.on('mouseout', function (event, d) {
clearHoverItem();
if (state.selectedNodeId === d.id) return;
d3.select(this).select('rect')
.transition()
.duration(200)
.attr('stroke', 'var(--main-light-color-10)')
.attr('stroke-width', 1);
});
nodeGroupEnter.append('rect')
.attr('width', (d: any) => d.width)
.attr('height', (d: any) => d.height)
.attr('rx', 16)
.attr('fill', 'var(--main-light-color-20)')
.attr('stroke', d => state.selectedNodeId === d.id ? 'var(--main-color)' : 'var(--main-light-color-10)')
.attr('stroke-width', 2);
//
nodeGroupEnter.append('text')
.attr('x', d => d.width / 2)
.attr('y', d => d.height / 2 - 6) //
.attr('text-anchor', 'middle')
.attr('font-size', 16)
.attr('fill', 'var(--main-color)')
.attr('font-weight', 600)
.text(d => d.labels?.[0]?.text || 'Tool');
nodeGroupEnter.append('g').attr('class', 'node-status');
// enter+update
const nodeStatusGroup = nodeGroup.merge(nodeGroupEnter).select('.node-status');
//
nodeStatusGroup.each(function (d) {
const g = d3.select(this);
g.selectAll('*').remove(); //
const status = state.dataView.get(d.id)?.status || 'waiting';
if (status === 'running') {
g.append('circle')
.attr('cx', d.width / 2 - 32)
.attr('cy', d.height - 16)
.attr('r', 6)
.attr('fill', 'none')
.attr('stroke', 'var(--main-color)')
.attr('stroke-width', 3)
.attr('stroke-dasharray', 20)
.attr('stroke-dashoffset', 0)
.append('animateTransform')
.attr('attributeName', 'transform')
.attr('attributeType', 'XML')
.attr('type', 'rotate')
.attr('from', `0 ${(d.width / 2 - 32)} ${(d.height - 16)}`)
.attr('to', `360 ${(d.width / 2 - 32)} ${(d.height - 16)}`)
.attr('dur', '1s')
.attr('repeatCount', 'indefinite');
g.append('text')
.attr('x', d.width / 2 - 16)
.attr('y', d.height - 12)
.attr('font-size', 13)
.attr('fill', 'var(--main-color)')
.text('running');
} else if (status === 'waiting') {
g.append('circle')
.attr('cx', d.width / 2 - 32)
.attr('cy', d.height - 16)
.attr('r', 6)
.attr('fill', 'none')
.attr('stroke', '#bdbdbd')
.attr('stroke-width', 3);
g.append('text')
.attr('x', d.width / 2 - 16)
.attr('y', d.height - 12)
.attr('font-size', 13)
.attr('fill', '#bdbdbd')
.text('waiting');
} else if (status === 'success') {
g.append('circle')
.attr('cx', d.width / 2 - 32)
.attr('cy', d.height - 16)
.attr('r', 6)
.attr('fill', 'none')
.attr('stroke', '#4caf50')
.attr('stroke-width', 3);
g.append('text')
.attr('x', d.width / 2 - 16)
.attr('y', d.height - 12)
.attr('font-size', 13)
.attr('fill', '#4caf50')
.text('success');
} else if (status === 'error') {
g.append('circle')
.attr('cx', d.width / 2 - 32)
.attr('cy', d.height - 16)
.attr('r', 6)
.attr('fill', 'none')
.attr('stroke', '#f44336')
.attr('stroke-width', 3);
g.append('text')
.attr('x', d.width / 2 - 16)
.attr('y', d.height - 12)
.attr('font-size', 13)
.attr('fill', '#f44336')
.text('error');
}
});
// enter
nodeGroupEnter
.transition()
.duration(600)
.attr('opacity', 1)
.attr('transform', d => `translate(${(d.x || 0) + 30}, ${(d.y || 0) + 30})`);
// update
nodeGroup
.transition()
.duration(600)
.ease(d3.easeCubicInOut)
.attr('transform', d => `translate(${(d.x || 0) + 30}, ${(d.y || 0) + 30})`);
//
nodeGroup.select('rect')
.transition()
.duration(400)
.attr('stroke', d => state.selectedNodeId === d.id ? 'var(--main-color)' : 'var(--main-light-color-10)');
//
svg.selectAll<SVGLineElement, any>('.edge')
.on('mouseover', function () {
d3.select(this)
.transition()
.duration(200)
.attr('stroke', 'var(--main-color)')
.attr('stroke-width', 4.5);
context.setCaption(t('click-edge-to-delete'));
})
.on('mouseout', function () {
d3.select(this)
.transition()
.duration(200)
.attr('stroke', 'var(--main-color)')
.attr('stroke-width', 2.5);
context.setCaption('');
})
.on('click', function (event, d) {
// edge
state.edges = state.edges.filter(e => {
// edge
if (e.sections) {
// section
return !e.sections.some((section: any, idx: number) =>
((e.id || '') + '-' + (section.id || idx)) === d.id
);
}
// edge
return e.id !== d.id && e.id !== d.section?.id;
});
recomputeLayout().then(renderSvg);
event.stopPropagation();
});
//
prevNodes = state.nodes.map(n => ({ ...n }));
prevEdges = (state.edges || []).map(e => ({ ...e, sections: e.sections ? e.sections.map((s: any) => ({ ...s })) : [] }));
}
//
function resetConnections() {
if (!state.nodes.length) return;
const edges = [];
for (let i = 0; i < state.nodes.length - 1; ++i) {
const prev = state.nodes[i];
const next = state.nodes[i + 1];
edges.push({
id: prev.id + '-' + next.id,
sources: [prev.id],
targets: [next.id]
});
}
state.edges = edges;
recomputeLayout().then(renderSvg);
}
const context = inject('context') as any;
context.reset = resetConnections;
context.state = state;
context.render = renderSvg;
onMounted(() => {
nextTick(drawDiagram);
});
// 4.
function getNodePopupStyle(node: any): any {
// svg
// offsetXnode.xnode.y
const marginX = 50;
const marginY = 80;
const popupWidth = 300;
const popupHeight = 500;
let left = (node.x || 0) + (node.width || 160) + 100;
let top = (node.y || 0) + 30;
//
const container = svgContainer.value;
let containerWidth = 1200, containerHeight = 800; //
if (container) {
const rect = container.getBoundingClientRect();
containerWidth = rect.width;
containerHeight = rect.height;
}
// left top
left = Math.max(marginX, Math.min(left, containerWidth - popupWidth - marginX));
top = Math.max(marginY, Math.min(top, containerHeight - popupHeight - marginY));
return {
position: 'absolute',
left: `${left}px`,
top: `${top}px`,
width: `${popupWidth}px`,
height: `${popupHeight}px`
};
}
</script>
<style>
.diagram-container {
width: 100%;
min-height: 200px;
display: flex;
justify-content: center;
align-items: flex-start;
border-radius: 8px;
padding: 24px 0;
overflow-x: auto;
}
.node-popup {
position: absolute;
background: var(--background);
border: 1px solid var(--main-color);
border-radius: 8px;
padding: 8px 12px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
white-space: nowrap;
z-index: 10;
}
/* 旋转动画 */
.status-running-circle {
animation: spin 1s linear infinite;
transform-origin: center;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,173 +0,0 @@
<template>
<el-dialog v-model="showDialog" width="800px" class="no-padding-dialog">
<template #header>
<div style="display: flex; align-items: center;">
<span>Tool Diagram</span>
&ensp;
<el-button size="small" type="primary" @click="() => context.reset()">{{ t("reset") }}</el-button>
<!-- 自检程序弹出表单 -->
<el-popover placement="top" width="350" trigger="click" v-model:visible="testFormVisible">
<template #reference>
<el-button size="small" type="primary">
{{ t('start-auto-detect') }}
</el-button>
</template>
<el-input type="textarea" v-model="testPrompt" :rows="2" style="margin-bottom: 8px;"
placeholder="请输入 prompt" />
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<el-switch v-model="enableXmlWrapper" style="margin-right: 8px;" />
<span :style="{
opacity: enableXmlWrapper ? 1 : 0.7,
color: enableXmlWrapper ? 'var(--main-color)' : undefined
}">XML</span>
</div>
<div style="text-align: right;">
<el-button size="small" @click="testFormVisible = false">{{ t("cancel") }}</el-button>
<el-button size="small" type="primary" @click="onTestConfirm">
{{ t("confirm") }}
</el-button>
</div>
</el-popover>
</div>
</template>
<el-scrollbar height="80vh">
<Diagram :tab-id="props.tabId" />
</el-scrollbar>
<transition name="main-fade" mode="out-in">
<div class="caption" v-show="showCaption">
{{ caption }}
</div>
</transition>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, provide, ref } from 'vue';
import Diagram from './diagram.vue';
import { makeNodeTest, topoSortParallel, type DiagramContext, type DiagramState } from './diagram';
import { ElMessage } from 'element-plus';
import { useI18n } from 'vue-i18n';
import type { ToolStorage } from '../tools';
import { tabs } from '../../panel';
const { t } = useI18n();
const caption = ref('');
const showCaption = ref(false);
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
tabId: {
type: Number,
required: true
}
});
const emit = defineEmits(['update:modelValue']);
const showDialog = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v)
});
function setCaption(text: string) {
caption.value = text;
if (caption.value) {
nextTick(() => {
showCaption.value = true;
});
} else {
nextTick(() => {
showCaption.value = false;
});
}
}
const context: DiagramContext = {
reset: () => { },
render: () => { },
state: undefined,
setCaption
};
provide('context', context);
const tab = tabs.content[props.tabId];
const tabStorage = tab.storage as ToolStorage;
const autoDetectDiagram = tabStorage.autoDetectDiagram;
if (autoDetectDiagram) {
// ...
} else {
tabStorage.autoDetectDiagram = {
edges: [],
views: []
};
}
//
const testFormVisible = ref(false);
const enableXmlWrapper = ref(false);
const testPrompt = ref('please call the tool {tool} to make some test');
async function onTestConfirm() {
testFormVisible.value = false;
// enableXmlWrapper.value testPrompt.value
const state = context.state;
tabStorage.autoDetectDiagram!.views = [];
if (state) {
const dispatches = topoSortParallel(state);
for (const nodeIds of dispatches) {
for (const id of nodeIds) {
const view = state.dataView.get(id);
if (view) {
await makeNodeTest(view, enableXmlWrapper.value, testPrompt.value, context)
tabStorage.autoDetectDiagram!.views!.push({
tool: view.tool,
status: view.status,
function: view.function,
result: view.result
});
}
}
}
} else {
ElMessage.error('error');
}
}
</script>
<style>
.no-padding-dialog {
margin-top: 30px !important;
}
.no-padding-dialog .caption {
position: absolute;
left: 20px;
bottom: 10px;
margin: 0 auto;
width: fit-content;
min-height: 32px;
background: rgba(245, 247, 250, 0.05);
border-radius: 8px;
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.06);
color: var(--main-color);
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
z-index: 10;
transition: background 0.2s;
}
</style>

View File

@ -1,41 +1,29 @@
<template>
<el-scrollbar height="100%">
<AutoDetector :tab-id="props.tabId" />
<div class="tool-module">
<div class="left">
<h2>
<span class="iconfont icon-tool"></span>
{{ t('tool-module') }}
<el-button
style="font-size: 12px;"
@click="showAutoDetector = true"
>
{{ t('tool-self-detect') }}
</el-button>
工具模块
</h2>
<ToolList :tab-id="props.tabId"></ToolList>
</div>
<div class="right">
<ToolExecutor :tab-id="props.tabId"></ToolExecutor>
<ToolLogger :tab-id="props.tabId"></ToolLogger>
</div>
</div>
<AutoDetector
v-model="showAutoDetector"
:tab-id="props.tabId"
/>
</el-scrollbar>
</template>
<script setup lang="ts">
import { defineProps, ref } from 'vue';
import { defineProps } from 'vue';
import ToolList from './tool-list.vue';
import ToolExecutor from './tool-executor.vue';
import ToolLogger from './tool-logger.vue';
import AutoDetector from './auto-detector/index.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
tabId: {
@ -43,8 +31,6 @@ const props = defineProps({
required: true
}
});
const showAutoDetector = ref(false);
</script>
<style scoped>

View File

@ -1,128 +0,0 @@
<template>
<div class="swim-pool">
<transition-group name="lane-move" tag="div">
<div v-for="(lane, laneIdx) in lanes" :key="lane.id" class="swim-lane" @dragover.prevent
@drop="onDrop(laneIdx)">
<div class="lane-title">Group {{ laneIdx + 1 }}</div>
<div v-for="tool in lane.tools" :key="tool.name" class="tool-card" draggable="true"
@dragstart="onDragStart(tool, laneIdx)">
{{ tool.name }}
</div>
</div>
</transition-group>
</div>
</template>
<script setup lang="ts">
import type { ToolItem } from '@/hook/type';
import { mcpClientAdapter } from '@/views/connect/core';
import { ref, onMounted } from 'vue';
//
interface Tool {
id: string;
name: string;
}
interface Lane {
id: string;
tools: ToolItem[];
}
//
const tools = ref<ToolItem[]>([]);
//
const lanes = ref<Lane[]>([]);
//
const getAllTools = async () => {
const items = [];
for (const client of mcpClientAdapter.clients) {
const clientTools = await client.getTools();
items.push(...clientTools.values());
}
return items;
};
//
onMounted(async () => {
tools.value = await getAllTools();
console.log(tools.value);
lanes.value = tools.value.map((tool, idx) => ({
id: `lane-${idx}`,
tools: [tool]
}));
});
//
let dragInfo: { tool: ToolItem | null; fromLane: number } = { tool: null, fromLane: -1 };
//
function onDragStart(tool: ToolItem, fromLane: number) {
dragInfo = { tool, fromLane };
}
//
function onDrop(toLane: number) {
if (dragInfo.tool && dragInfo.fromLane !== -1 && dragInfo.fromLane !== toLane) {
//
lanes.value[dragInfo.fromLane].tools = lanes.value[dragInfo.fromLane].tools.filter(
t => t.name !== dragInfo.tool!.name
);
//
lanes.value[toLane].tools.push(dragInfo.tool);
//
if (lanes.value[dragInfo.fromLane].tools.length === 0) {
lanes.value.splice(dragInfo.fromLane, 1);
}
//
lanes.value = lanes.value.map((lane, idx) => ({
...lane,
// id
id: `lane-${idx}`
}));
}
dragInfo = { tool: null, fromLane: -1 };
}
</script>
<style scoped>
.swim-pool {
display: flex;
flex-direction: column;
gap: 12px;
}
.swim-lane {
border: 1px solid var(--sidebar);
min-height: 60px;
margin-bottom: 10px;
padding: 8px;
border-radius: 8px;
transition: box-shadow 0.3s;
box-shadow: 0 2px 8px 0 rgba(0,0,0,0.08);
}
.lane-title {
font-weight: bold;
margin-bottom: 6px;
}
.tool-card {
border: 1px solid var(--main-color);
border-radius: 4px;
padding: 4px 12px;
margin-bottom: 4px;
cursor: grab;
user-select: none;
}
/* 动画样式 */
.lane-move-move {
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View File

@ -5,31 +5,42 @@
<div class="tool-executor-container">
<el-form :model="tabStorage.formData" :rules="formRules" ref="formRef" label-position="top">
<template v-if="currentTool?.inputSchema?.properties">
<el-form-item v-for="[name, property] in Object.entries(currentTool.inputSchema.properties)" :key="name"
:label="property.title || name" :prop="name"
:required="currentTool.inputSchema.required?.includes(name)">
<el-input v-if="property.type === 'string'" v-model="tabStorage.formData[name]" type="text"
:placeholder="property.description || t('enter') + ' ' + (property.title || name)"
@keydown.enter.prevent="handleExecute" />
<el-input-number v-else-if="property.type === 'number' || property.type === 'integer'"
v-model="tabStorage.formData[name]" controls-position="right"
:placeholder="property.description || t('enter') + ' ' + (property.title || name)"
@keydown.enter.prevent="handleExecute" />
<el-switch v-else-if="property.type === 'boolean'" active-text="true" inactive-text="false"
v-model="tabStorage.formData[name]" />
<el-input-tag
v-else-if="property.type === 'array'"
<el-form-item
v-for="[name, property] in Object.entries(currentTool.inputSchema.properties)"
:key="name"
:label="property.title || name"
:prop="name"
:required="currentTool.inputSchema.required?.includes(name)"
>
<el-input
v-if="property.type === 'string'"
v-model="tabStorage.formData[name]"
:placeholder="property.description || t('enter') + ' ' + (property.title || name) + ' (逗号分隔)'"
type="text"
:placeholder="property.description || t('enter') + ' ' + (property.title || name)"
@keydown.enter.prevent="handleExecute"
/>
<k-input-object v-else-if="property.type === 'object'" v-model="tabStorage.formData[name]"
:schema="property"
:placeholder="property.description || t('enter') + ' ' + (property.title || name)" />
<el-input-number
v-else-if="property.type === 'number' || property.type === 'integer'"
v-model="tabStorage.formData[name]"
controls-position="right"
:placeholder="property.description || t('enter') + ' ' + (property.title || name)"
@keydown.enter.prevent="handleExecute"
/>
<el-switch
v-else-if="property.type === 'boolean'"
active-text="true"
inactive-text="false"
v-model="tabStorage.formData[name]"
/>
<k-input-object
v-else-if="property.type === 'object'"
v-model="tabStorage.formData[name]"
:schema="property"
:placeholder="property.description || t('enter') + ' ' + (property.title || name)"
/>
</el-form-item>
</template>
@ -40,35 +51,6 @@
<el-button @click="resetForm">
{{ t('reset') }}
</el-button>
<el-button @click="generateMockData" :loading="mockLoading"
:disabled="loading || aiMockLoading || mockLoading">
{{ 'mook' }}
</el-button>
<el-popover placement="top" width="350" trigger="click" v-model:visible="aiPromptVisible">
<template #reference>
<el-button :loading="aiMockLoading" :disabled="loading || aiMockLoading || mockLoading">
{{ 'AI' }}
</el-button>
</template>
<div style="margin-bottom: 8px; font-weight: bold;">
{{ t('edit-ai-mook-prompt') }}
</div>
<el-input type="textarea" v-model="aiMookPrompt" :rows="2" style="margin-bottom: 8px;" />
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<el-switch
v-model="enableXmlWrapper"
style="margin-right: 8px;"
/>
<span style="opacity: 0.7;">XML</span>
</div>
<div style="text-align: right;">
<el-button size="small" @click="aiPromptVisible = false">{{ t('cancel') }}</el-button>
<el-button size="small" type="primary" :loading="aiMockLoading" @click="onAIMookConfirm">
{{ t('confirm') }}
</el-button>
</div>
</el-popover>
</el-form-item>
</el-form>
</div>
@ -77,20 +59,15 @@
<script setup lang="ts">
import { defineComponent, defineProps, watch, ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { tabs } from '../panel';
import type { ToolStorage } from './tools';
import { getDefaultValue, normaliseJavascriptType } from '@/hook/mcp';
import KInputObject from '@/components/k-input-object/index.vue';
import { mcpClientAdapter } from '@/views/connect/core';
import { JSONSchemaFaker } from 'json-schema-faker';
defineComponent({ name: 'tool-executor' });
const mockLoading = ref(false);
const aiMockLoading = ref(false);
const aiPromptVisible = ref(false);
const enableXmlWrapper = ref(false);
const { t } = useI18n();
@ -122,7 +99,6 @@ const currentTool = computed(() => {
}
});
const aiMookPrompt = ref(`please call the tool ${currentTool.value?.name || ''} to make some test`);
const formRules = computed<FormRules>(() => {
const rules: FormRules = {};
@ -134,7 +110,7 @@ const formRules = computed<FormRules>(() => {
rules[name] = [
{
required: true,
message: `${property.title || name} ` + t("is-required"),
message: `${property.title || name} 是必填字段`,
trigger: 'blur'
}
];
@ -156,8 +132,7 @@ const initFormData = () => {
Object.entries(currentTool.value.inputSchema.properties).forEach(([name, property]) => {
newSchemaDataForm[name] = getDefaultValue(property);
const rawType = Array.isArray(tabStorage.formData[name]) ? 'array' : typeof tabStorage.formData[name];
const originType = normaliseJavascriptType(rawType);
const originType = normaliseJavascriptType(typeof tabStorage.formData[name]);
if (tabStorage.formData[name] !== undefined && originType === property.type) {
newSchemaDataForm[name] = tabStorage.formData[name];
@ -171,94 +146,6 @@ const resetForm = () => {
formRef.value?.resetFields();
};
import { TaskLoop } from '@/components/main-panel/chat/core/task-loop';
import type { ChatStorage } from '../chat/chat-box/chat';
const onAIMookConfirm = async () => {
aiPromptVisible.value = false;
await generateAIMockData(aiMookPrompt.value);
};
const generateAIMockData = async (prompt?: string) => {
if (!currentTool.value?.inputSchema) return;
aiMockLoading.value = true;
try {
const loop = new TaskLoop({ maxEpochs: 1 });
const usePrompt = prompt || `please call the tool ${currentTool.value.name} to make some test`;
const chatStorage = {
messages: [],
settings: {
temperature: 0.6,
systemPrompt: '',
enableTools: [{
name: currentTool.value.name,
description: currentTool.value.description,
inputSchema: currentTool.value.inputSchema,
enabled: true
}],
enableWebSearch: false,
contextLength: 5,
enableXmlWrapper: enableXmlWrapper.value,
parallelToolCalls: false
}
} as ChatStorage;
loop.setMaxEpochs(1);
let aiMockJson: any = undefined;
loop.registerOnToolCall(toolCall => {
console.log(toolCall);
if (toolCall.function?.name === currentTool.value?.name) {
try {
const toolArgs = JSON.parse(toolCall.function?.arguments || '{}');
aiMockJson = toolArgs;
} catch (e) {
ElMessage.error('AI 生成的 JSON 解析错误');
}
} else {
ElMessage.error('AI 调用了未知的工具');
}
loop.abort();
return toolCall;
});
loop.registerOnError(error => {
ElMessage.error(error + '');
});
await loop.start(chatStorage, usePrompt);
if (aiMockJson && typeof aiMockJson === 'object') {
Object.keys(aiMockJson).forEach(key => {
tabStorage.formData[key] = aiMockJson[key];
});
formRef.value?.clearValidate?.();
}
} finally {
aiMockLoading.value = false;
}
};
const generateMockData = async () => {
if (!currentTool.value?.inputSchema) return;
mockLoading.value = true;
try {
JSONSchemaFaker.option({
useDefaultValue: true,
alwaysFakeOptionals: true
});
const mockData = await JSONSchemaFaker.resolve(currentTool.value.inputSchema as any) as any;
Object.keys(mockData).forEach(key => {
tabStorage.formData[key] = mockData[key];
});
formRef.value?.clearValidate?.();
} finally {
mockLoading.value = false;
}
};
async function handleExecute() {
if (!currentTool.value) return;
loading.value = true;
@ -271,15 +158,10 @@ async function handleExecute() {
}
}
watch(currentTool, (tool) => {
aiMookPrompt.value = `please call the tool ${tool?.name || ''} to make some test`;
});
watch(() => tabStorage.currentToolName, () => {
initFormData();
resetForm();
}, { immediate: true });
</script>
<style>
@ -303,13 +185,4 @@ watch(() => tabStorage.currentToolName, () => {
border: 1px solid var(--main-color) !important;
}
.el-button:active {
transform: scale(0.95);
transition: transform 0.08s;
}
.el-tag.el-tag--info {
background-color: var(--main-color);
}
</style>

View File

@ -19,8 +19,6 @@
<!-- body -->
<div class="tool-list-container-scrollbar">
<el-scrollbar height="fit-content">
<div class="tool-list-container-scrollbar">
<el-scrollbar height="fit-content" v-if="(client.tools?.size || 0) > 0">
<div class="tool-list-container">
<div class="item" :class="{ 'active': tabStorage.currentToolName === tool.name }"
v-for="tool of client.tools?.values()" :key="tool.name" @click="handleClick(tool)">
@ -30,15 +28,6 @@
</div>
</div>
</el-scrollbar>
<div v-else style="padding: 10px;">
<div class="empty-description">
<span class="iconfont icon-empty"
style="font-size: 22px; opacity: 0.4; margin-right: 6px;"></span>
<span style="opacity: 0.6;">No tools found.</span>
</div>
</div>
</div>
</el-scrollbar>
</div>
</el-collapse-item>
</el-collapse>
@ -117,7 +106,7 @@ onMounted(async () => {
width: 175px;
}
.tool-list-container>.item {
.tool-list-container > .item {
margin: 3px;
padding: 5px 10px;
border-radius: .3em;
@ -128,11 +117,6 @@ onMounted(async () => {
transition: var(--animation-3s);
}
.tool-list-container>.item:active {
transform: scale(0.95);
transition: var(--animation-3s);
}
.tool-list-container>.item:hover {
background-color: var(--main-light-color);
transition: var(--animation-3s);
@ -174,13 +158,4 @@ onMounted(async () => {
opacity: 0.6;
font-size: 12.5px;
}
.empty-description {
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder, #bbb);
font-size: 15px;
min-height: 40px;
}
</style>

View File

@ -20,14 +20,8 @@
<div v-else>
<!-- 展示原本的信息 -->
<template v-if="!showRawJson && tabStorage.lastToolCallResponse">
<div
v-for="(c, idx) in tabStorage.lastToolCallResponse!.content"
:key="idx"
class="tool-call-block"
>
<pre class="tool-call-text">{{ c.text }}</pre>
</div>
<template v-if="!showRawJson">
{{tabStorage.lastToolCallResponse?.content.map(c => c.text).join('\n')}}
</template>
<!-- 展示 json -->
@ -111,21 +105,4 @@ const showRawJson = ref(false);
padding: 5px 9px;
border-radius: .5em;
}
.tool-call-block {
margin-bottom: 12px;
padding: 10px 12px;
background: rgba(0,0,0,0.04);
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
}
.tool-call-text {
font-family: var(--code-font-family, monospace);
font-size: 15px;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
color: var(--el-text-color-primary, #222);
}
</style>

View File

@ -1,14 +1,8 @@
import type { ToolCallResponse } from '@/hook/type';
import type { Edge, Node, NodeDataView } from './auto-detector/diagram';
export interface ToolStorage {
activeNames: any[];
currentToolName: string;
lastToolCallResponse?: ToolCallResponse | string;
formData: Record<string, any>;
autoDetectDiagram?: {
edges?: Edge[];
views?: NodeDataView[];
[key: string]: any;
}
}

View File

@ -11,8 +11,6 @@ export function getDefaultValue(property: TypeAble): any {
return false;
} else if (property.type === 'object') {
return {};
} else if (property.type === 'array') {
return [];
} else {
return '';
}
@ -28,8 +26,6 @@ export function normaliseJavascriptType(type: string) {
return 'boolean';
case 'string':
return 'string';
case 'array':
return 'array';
default:
return type;
}

View File

@ -91,15 +91,3 @@ export function getImageBlobUrlByBase64(base64String: string, mimeType: string,
}
return blobUrl;
}
export function gotoWebsite(url: string) {
const platform = getPlatform();
const bridge = useMessageBridge();
if (platform === 'vscode') {
// For VSCode, use the webview API to open external links
bridge.commandRequest('vscode/openExternal', { url });
} else if (platform === 'web') {
// For web, use the standard window.open method
window.open(url, '_blank');
}
}

View File

@ -182,18 +182,5 @@
"copy": "نسخ",
"export": "تصدير",
"export-filename": "اسم ملف التصدير",
"how-to-use": "كيفية الاستخدام؟",
"is-required": "هو حقل مطلوب",
"edit-ai-mook-prompt": "تحرير إشارات AI Mook",
"start-auto-detect": "بدء عملية الفحص الذاتي",
"tool-module": "وحدة الأدوات",
"prompt-module": "وحدة المطالبات",
"not-select-begin-node": "لم يتم تحديد عقدة البداية",
"can-make-loop": "سيؤدي الاتصال إلى تكوين حلقة",
"this-is-repeat-connection": "هذا رابط مكرر",
"ai-gen-error-json": "خطأ في تحليل JSON الذي تم إنشاؤه بواسطة الذكاء الاصطناعي",
"ai-invoke-unknown-tool": "استدعت الذكاء الاصطناعي أداة غير معروفة",
"click-edge-to-delete": "انقر على الحافة للحذف",
"select-node-define-test-tomo": "اختر عقدة أخرى لتحديد طوبولوجيا الاختبار",
"tool-self-detect": "الفحص الذاتي للأداة"
"how-to-use": "كيفية الاستخدام؟"
}

View File

@ -182,18 +182,5 @@
"copy": "Kopieren",
"export": "Exportieren",
"export-filename": "Exportdateiname",
"how-to-use": "Wie benutzt man?",
"is-required": "ist ein Pflichtfeld",
"edit-ai-mook-prompt": "AI Mook-Prompts bearbeiten",
"start-auto-detect": "Selbsttest starten",
"tool-module": "Werkzeugmodul",
"prompt-module": "Aufforderungsmodul",
"not-select-begin-node": "Kein Startknoten ausgewählt",
"can-make-loop": "Die Verbindung wird eine Schleife bilden",
"this-is-repeat-connection": "Dies ist ein doppelter Link",
"ai-gen-error-json": "Fehler beim Parsen von KI-generiertem JSON",
"ai-invoke-unknown-tool": "KI hat ein unbekanntes Tool aufgerufen",
"click-edge-to-delete": "Klicken Sie auf die Kante, um sie zu löschen",
"select-node-define-test-tomo": "Wählen Sie einen anderen Knoten aus, um die Testtopologie zu definieren",
"tool-self-detect": "Werkzeug-Selbsttest"
"how-to-use": "Wie benutzt man?"
}

View File

@ -182,18 +182,5 @@
"copy": "Copy",
"export": "Export",
"export-filename": "Export filename",
"how-to-use": "How to use?",
"is-required": "is a required field",
"edit-ai-mook-prompt": "Edit AI Mook prompts",
"start-auto-detect": "Start self-check",
"tool-module": "Tool module",
"prompt-module": "Prompt Module",
"not-select-begin-node": "No starting node selected",
"can-make-loop": "The connection will form a loop",
"this-is-repeat-connection": "This is a duplicate link",
"ai-gen-error-json": "AI-generated JSON parsing error",
"ai-invoke-unknown-tool": "AI called an unknown tool",
"click-edge-to-delete": "Click the edge to delete",
"select-node-define-test-tomo": "Select another node to define the test topology",
"tool-self-detect": "Tool Self-Check"
"how-to-use": "How to use?"
}

View File

@ -182,18 +182,5 @@
"copy": "Copier",
"export": "Exporter",
"export-filename": "Nom du fichier d'exportation",
"how-to-use": "Comment utiliser ?",
"is-required": "est un champ obligatoire",
"edit-ai-mook-prompt": "Modifier les invites AI Mook",
"start-auto-detect": "Démarrer l'autovérification",
"tool-module": "Module d'outils",
"prompt-module": "Module d'invite",
"not-select-begin-node": "Aucun nœud de départ sélectionné",
"can-make-loop": "La connexion formera une boucle",
"this-is-repeat-connection": "Ceci est un lien en double",
"ai-gen-error-json": "Erreur d'analyse JSON générée par IA",
"ai-invoke-unknown-tool": "L'IA a appelé un outil inconnu",
"click-edge-to-delete": "Cliquez sur le bord pour supprimer",
"select-node-define-test-tomo": "Sélectionnez un autre nœud pour définir la topologie de test",
"tool-self-detect": "Auto-vérification de l'outil"
"how-to-use": "Comment utiliser ?"
}

View File

@ -182,18 +182,5 @@
"copy": "コピー",
"export": "エクスポート",
"export-filename": "エクスポートファイル名",
"how-to-use": "使用方法",
"is-required": "は必須フィールドです",
"edit-ai-mook-prompt": "AI Mookプロンプトを編集",
"start-auto-detect": "自己診断を開始",
"tool-module": "ツールモジュール",
"prompt-module": "プロンプトモジュール",
"not-select-begin-node": "開始ノードが選択されていません",
"can-make-loop": "接続によりループが形成されます",
"this-is-repeat-connection": "これは重複したリンクです",
"ai-gen-error-json": "AI生成JSONの解析エラー",
"ai-invoke-unknown-tool": "AIが未知のツールを呼び出しました",
"click-edge-to-delete": "クリックして削除",
"select-node-define-test-tomo": "テストトポロジを定義するために別のノードを選択してください",
"tool-self-detect": "ツール自己診断"
"how-to-use": "使用方法"
}

View File

@ -182,18 +182,5 @@
"copy": "복사",
"export": "내보내기",
"export-filename": "내보내기 파일 이름",
"how-to-use": "사용 방법?",
"is-required": "는 필수 필드입니다",
"edit-ai-mook-prompt": "AI Mook 프롬프트 편집",
"start-auto-detect": "자체 점검 시작",
"tool-module": "도구 모듈",
"prompt-module": "프롬프트 모듈",
"not-select-begin-node": "시작 노드가 선택되지 않았습니다",
"can-make-loop": "연결이 루프를 형성합니다",
"this-is-repeat-connection": "이것은 중복된 링크입니다",
"ai-gen-error-json": "AI 생성 JSON 구문 분석 오류",
"ai-invoke-unknown-tool": "AI가 알 수 없는 도구를 호출했습니다",
"click-edge-to-delete": "가장자리를 클릭하여 삭제",
"select-node-define-test-tomo": "테스트 토폴로지를 정의하려면 다른 노드를 선택하세요",
"tool-self-detect": "도구 자체 점검"
"how-to-use": "사용 방법?"
}

View File

@ -182,18 +182,5 @@
"copy": "Копировать",
"export": "Экспорт",
"export-filename": "Имя файла экспорта",
"how-to-use": "Как использовать?",
"is-required": "является обязательным полем",
"edit-ai-mook-prompt": "Редактировать подсказки AI Mook",
"start-auto-detect": "Запустить самопроверку",
"tool-module": "Модуль инструментов",
"prompt-module": "Модуль подсказок",
"not-select-begin-node": "Начальный узел не выбран",
"can-make-loop": "Соединение образует петлю",
"this-is-repeat-connection": "Это повторяющаяся ссылка",
"ai-gen-error-json": "Ошибка разбора JSON, созданного ИИ",
"ai-invoke-unknown-tool": "ИИ вызвал неизвестный инструмент",
"click-edge-to-delete": "Нажмите на край, чтобы удалить",
"select-node-define-test-tomo": "Выберите другой узел для определения тестовой топологии",
"tool-self-detect": "Самопроверка инструмента"
"how-to-use": "Как использовать?"
}

View File

@ -182,18 +182,5 @@
"copy": "复制",
"export": "导出",
"export-filename": "导出文件名",
"how-to-use": "如何使用?",
"is-required": "是必填字段",
"edit-ai-mook-prompt": "编辑 AI Mook 提示词",
"start-auto-detect": "开启自检程序",
"tool-module": "工具模块",
"prompt-module": "提示词模块",
"not-select-begin-node": "未选择起始节点",
"can-make-loop": "连接会形成环路",
"this-is-repeat-connection": "这是一个重复的连接",
"ai-gen-error-json": "AI 生成的 JSON 解析错误",
"ai-invoke-unknown-tool": "AI 调用了未知的工具",
"click-edge-to-delete": "点击边以删除",
"select-node-define-test-tomo": "选择另一个节点以定义测试拓扑",
"tool-self-detect": "工具自检"
"how-to-use": "如何使用?"
}

View File

@ -182,18 +182,5 @@
"copy": "複製",
"export": "導出",
"export-filename": "導出文件名",
"how-to-use": "如何使用?",
"is-required": "是必填欄位",
"edit-ai-mook-prompt": "編輯AI Mook提示詞",
"start-auto-detect": "開啟自檢程序",
"tool-module": "工具模組",
"prompt-module": "提示詞模組",
"not-select-begin-node": "未選擇起始節點",
"can-make-loop": "連接會形成環路",
"this-is-repeat-connection": "這是一個重複的連結",
"ai-gen-error-json": "AI 生成的 JSON 解析錯誤",
"ai-invoke-unknown-tool": "AI調用了未知的工具",
"click-edge-to-delete": "點擊邊緣以刪除",
"select-node-define-test-tomo": "選擇另一個節點以定義測試拓撲",
"tool-self-detect": "工具自檢"
"how-to-use": "如何使用?"
}

View File

@ -27,7 +27,7 @@
<div style="display: inline-flex;">
<el-button class="join-qq" type="primary"
@click="gotoWebsite('https://qm.qq.com/cgi-bin/qm/qr?k=C6ZUTZvfqWoI12lWe7L93cWa1hUsuVT0&jump_from=webapi&authKey=McW6B1ogTPjPDrCyGttS890tMZGQ1KB3QLuG4aqVNRaYp4vlTSgf2c6dMcNjMuBD')">
@click="joinQQGroup('https://qm.qq.com/cgi-bin/qm/qr?k=C6ZUTZvfqWoI12lWe7L93cWa1hUsuVT0&jump_from=webapi&authKey=McW6B1ogTPjPDrCyGttS890tMZGQ1KB3QLuG4aqVNRaYp4vlTSgf2c6dMcNjMuBD')">
<span class="iconfont icon-QQ"></span>
{{ t('join-discussion') }}
</el-button>
@ -42,17 +42,24 @@
</template>
<script setup lang="ts">
import { gotoWebsite } from '@/hook/util';
import { defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const version = '0.1.9';
const version = '0.1.8';
const author = 'LSTM-Kirigaya (锦恢)';
defineComponent({ name: 'about' });
function joinQQGroup(url: string) {
window.open(url, '_blank');
}
function gotoWebsite(url: string) {
window.open(url, '_blank');
}
</script>
<style>

View File

@ -108,7 +108,6 @@ function chooseDebugMode(index: number) {
.welcome-container > span {
flex: 1 1 calc(50% - 100px);
box-sizing: border-box;
transition: .3s transform ease-in-out;
}
.debug-option {

View File

@ -32,11 +32,6 @@ export function onGeneralColorChange(colorString: string) {
document?.documentElement.style.setProperty(
'--main-light-color', `rgba(${r}, ${g}, ${b}, 0.7)`);
for (let i = 1; i <= 9; ++ i) {
document?.documentElement.style.setProperty(
`--main-light-color-${i}0`, `rgba(${r}, ${g}, ${b}, 0.${i})`);
}
}
export const predefinedColors = [

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import { setRunningCWD, setVscodeWorkspace } from '../openmcp-sdk/service/index.js';
import { launch } from './common/entry.js';
import { initialiseI18n, getAvailableKeys } from './i18n/index.js';
import { initialiseI18n } from './i18n/index.js';
export function activate(context: vscode.ExtensionContext) {
console.log('activate openmcp');
@ -13,11 +13,6 @@ export function activate(context: vscode.ExtensionContext) {
setVscodeWorkspace(workspace);
setRunningCWD(context.extensionPath);
initialiseI18n(context);
// 添加i18n调试信息
console.log('Current language:', vscode.env.language);
console.log('Available i18n keys:', getAvailableKeys().length);
launch(context);
}

View File

@ -2,7 +2,6 @@ import * as vscode from 'vscode';
import * as os from 'os';
import * as fspath from 'path';
import * as fs from 'fs';
import { t } from './i18n';
export type FsPath = string;
export const panels = new Map<FsPath, vscode.WebviewPanel>();
@ -97,7 +96,7 @@ export function getConnectionConfig() {
export function getWorkspaceConnectionConfigPath() {
const workspace = getWorkspacePath();
if (!workspace) {
return null; // 如果没有工作区,则返回 null
throw new Error('No workspace found. Please open a folder in VSCode first.');
}
const configDir = fspath.join(workspace, '.openmcp');
if (!fs.existsSync(configDir)) {
@ -111,14 +110,14 @@ export function getWorkspaceConnectionConfigPath() {
* @description {workspace}
* @param workspace
*/
export function getWorkspaceConnectionConfig():IConnectionConfig| null {
export function getWorkspaceConnectionConfig() {
if (_workspaceConnectionConfig) {
return _workspaceConnectionConfig;
}
const workspace = getWorkspacePath();
if (!workspace) {
return null; // 如果没有工作区,则返回 null
throw new Error('No workspace found. Please open a folder in VSCode first.');
}
const configDir = fspath.join(workspace, '.openmcp');
const connectionConfig = fspath.join(configDir, CONNECTION_CONFIG_NAME);
@ -224,17 +223,24 @@ export function saveWorkspaceConnectionConfig(workspace: string) {
export function updateWorkspaceConnectionConfig(
name: string,
absPath: string,
data: McpOptions[]
) {
const connectionItem = getWorkspaceConnectionConfigItemByName(name);
const connectionItem = getWorkspaceConnectionConfigItemByPath(absPath);
const workspaceConnectionConfig = getWorkspaceConnectionConfig();
if (!workspaceConnectionConfig) {
console.error('没有工作区连接配置文件,请先创建一个工作区连接');
return;
// 如果存在,删除老的 connectionItem
if (connectionItem) {
const index = workspaceConnectionConfig.items.indexOf(connectionItem);
if (index !== -1) {
workspaceConnectionConfig.items.splice(index, 1);
}
}
// 对于第一个 item 添加 filePath
// 对路径进行标准化
data.forEach(item => {
item.filePath = absPath.replace(/\\/g, '/');
item.cwd = item.cwd?.replace(/\\/g, '/');
item.name = item.serverInfo?.name;
item.version = item.serverInfo?.version;
@ -243,25 +249,8 @@ export function updateWorkspaceConnectionConfig(
console.log('get connectionItem: ', data);
// 如果存在,替换老的 connectionItem
if (connectionItem) {
console.log("存在的 connection")
const index = workspaceConnectionConfig.items.indexOf(connectionItem);
if (index !== -1) {
// 替换现有项目而不是删除后插入到开头
console.log("替换现有项目而不是删除后插入到开头")
workspaceConnectionConfig.items[index] = data;
} else {
// 如果索引查找失败,则插入到第一个
console.log("没有找到现有项目,插入到第一个")
// 插入到第一个
workspaceConnectionConfig.items.unshift(data);
}
} else {
// 没有找到现有项目,插入到第一个
console.log("没有找到现有项目,插入到第一个")
workspaceConnectionConfig.items.unshift(data);
}
const workspacePath = getWorkspacePath();
saveWorkspaceConnectionConfig(workspacePath);
vscode.commands.executeCommand('openmcp.sidebar.workspace-connection.refresh');
@ -269,15 +258,24 @@ export function updateWorkspaceConnectionConfig(
}
export function updateInstalledConnectionConfig(
name: string,
absPath: string,
data: McpOptions[]
) {
const connectionItem = getInstalledConnectionConfigItemByName(name);
const connectionItem = getInstalledConnectionConfigItemByPath(absPath);
const installedConnectionConfig = getConnectionConfig();
// 如果存在,删除老的 connectionItem
if (connectionItem) {
const index = installedConnectionConfig.items.indexOf(connectionItem);
if (index !== -1) {
installedConnectionConfig.items.splice(index, 1);
}
}
// 对于第一个 item 添加 filePath
// 对路径进行标准化
data.forEach(item => {
item.filePath = absPath.replace(/\\/g, '/');
item.cwd = item.cwd?.replace(/\\/g, '/');
item.name = item.serverInfo?.name;
item.version = item.serverInfo?.version;
@ -286,21 +284,8 @@ export function updateInstalledConnectionConfig(
console.log('get connectionItem: ', data);
// 如果存在,替换老的 connectionItem
if (connectionItem) {
const index = installedConnectionConfig.items.indexOf(connectionItem);
if (index !== -1) {
// 替换现有项目而不是删除后插入到开头
installedConnectionConfig.items[index] = data;
} else {
// 如果索引查找失败,则插入到第一个
// 插入到第一个
installedConnectionConfig.items.unshift(data);
}
} else {
// 没有找到现有项目,插入到第一个
installedConnectionConfig.items.unshift(data);
}
saveConnectionConfig();
vscode.commands.executeCommand('openmcp.sidebar.installed-connection.refresh');
}
@ -331,10 +316,6 @@ export function getWorkspacePath() {
export function getWorkspaceConnectionConfigItemByPath(absPath: string) {
const workspacePath = getWorkspacePath();
const workspaceConnectionConfig = getWorkspaceConnectionConfig();
if (!workspaceConnectionConfig) {
return null; // 如果没有工作区连接配置文件,则返回 null
}
const normaliseAbsPath = absPath.replace(/\\/g, '/');
for (let item of workspaceConnectionConfig.items) {
@ -349,44 +330,6 @@ export function getWorkspaceConnectionConfigItemByPath(absPath: string) {
return undefined;
}
/**
* @description mcp
* @param absPath
*/
export function getWorkspaceConnectionConfigItemByName(name: string) {
const workspaceConnectionConfig = getWorkspaceConnectionConfig();
if (!workspaceConnectionConfig) {
return null; // 如果没有工作区连接配置文件,则返回 null
}
for (let item of workspaceConnectionConfig.items) {
const nItem = Array.isArray(item) ? item[0] : item;
if (nItem.name === name) {
return item;
}
}
return undefined;
}
/**
* @description mcp
* @param absPath
*/
export function getInstalledConnectionConfigItemByName(name: string) {
const installedConnectionConfig = getConnectionConfig();
for (let item of installedConnectionConfig.items) {
const nItem = Array.isArray(item) ? item[0] : item;
if (nItem.name) {
return item;
}
}
return undefined;
}
/**
* @description mcp
* @param absPath

View File

@ -1,81 +1,26 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
const defaultBundle: Record<string, string> = {}
export function initialiseI18n(context: vscode.ExtensionContext) {
if (vscode.l10n.bundle === undefined) {
// 获取用户的语言设置
const userLanguage = vscode.env.language;
// 尝试加载用户语言对应的语言包
let bundlePath = context.asAbsolutePath(`l10n/bundle.l10n.${userLanguage}.json`);
// 如果用户语言的语言包不存在,回退到英语
if (!fs.existsSync(bundlePath)) {
bundlePath = context.asAbsolutePath('l10n/bundle.l10n.en.json');
}
try {
const bundlePath = context.asAbsolutePath('l10n/bundle.l10n.en.json');
const bundle = JSON.parse(fs.readFileSync(bundlePath, { encoding: 'utf-8' })) as Record<string, string>;
Object.assign(defaultBundle, bundle);
} catch (error) {
console.error('Failed to load i18n bundle:', error);
// 如果加载失败,尝试加载英语包作为最后的回退
try {
const fallbackPath = context.asAbsolutePath('l10n/bundle.l10n.en.json');
const fallbackBundle = JSON.parse(fs.readFileSync(fallbackPath, { encoding: 'utf-8' })) as Record<string, string>;
Object.assign(defaultBundle, fallbackBundle);
} catch (fallbackError) {
console.error('Failed to load fallback i18n bundle:', fallbackError);
}
}
}
}
export function t(message: string, ...args: string[]): string {
if (vscode.l10n.bundle === undefined) {
// 使用自定义的语言包
let translateMessage = defaultBundle[message] || message;
// 替换占位符 {0}, {1}, {2} 等
for (let i = 0; i < args.length; i++) {
for (let i = 0; i < args.length; ++ i) {
translateMessage = translateMessage.replace(`{${i}}`, args[i]);
}
return translateMessage;
} else {
// 使用 VS Code 的 l10n API
return vscode.l10n.t(message, ...args);
}
}
/**
* 使
*/
export function getCurrentLanguage(): string {
return vscode.env.language;
}
/**
*
*/
export function getAvailableKeys(): string[] {
if (vscode.l10n.bundle === undefined) {
return Object.keys(defaultBundle);
}
return [];
}
/**
*
*/
export function hasTranslation(key: string): boolean {
if (vscode.l10n.bundle === undefined) {
return key in defaultBundle;
}
// VS Code 的 l10n API 没有直接的方法检查,我们尝试翻译并比较
const translated = vscode.l10n.t(key);
return translated !== key;
}

View File

@ -44,6 +44,20 @@ export async function deleteInstalledConnection(item: McpOptions[] | McpOptions)
}
}
export async function validateAndGetCommandPath(commandString: string, cwd?: string): Promise<string> {
try {
const commands = commandString.split(' ');
const command = commands[0];
const args = commands.slice(1);
const process = spawn(command, args || [], { shell: true, cwd });
process.disconnect();
return '';
} catch (error) {
console.log(error);
throw new Error(`Cannot find command: ${commandString.split(' ')[0]}`);
}
}
export async function acquireInstalledConnection(): Promise<McpOptions[]> {
// 让用户选择连接类型
@ -74,6 +88,14 @@ export async function acquireInstalledConnection(): Promise<McpOptions[]> {
placeHolder: t('please-enter-cwd-placeholder')
});
// 校验 command + cwd 是否有效
try {
const commandPath = await validateAndGetCommandPath(commandString, cwd);
console.log('Command Path:', commandPath);
} catch (error) {
vscode.window.showErrorMessage(`Invalid command: ${error}`);
return [];
}
const commands = commandString.split(' ');
const command = commands[0];

View File

@ -4,7 +4,6 @@ import { getWorkspaceConnectionConfig, getWorkspaceConnectionConfigPath, getWork
import { ConnectionViewItem } from './common.js';
import { revealOpenMcpWebviewPanel } from '../webview/webview.service.js';
import { acquireUserCustomConnection, deleteUserConnection } from './workspace.service.js';
import { t } from '../i18n/index.js';
@RegisterTreeDataProvider('openmcp.sidebar.workspace-connection')
export class McpWorkspaceConnectProvider implements vscode.TreeDataProvider<ConnectionViewItem> {
@ -61,18 +60,14 @@ export class McpWorkspaceConnectProvider implements vscode.TreeDataProvider<Conn
@RegisterCommand('addConnection')
public async addConnection(context: vscode.ExtensionContext) {
const workspaceConnectionConfig = getWorkspaceConnectionConfig();
if (!workspaceConnectionConfig) {
vscode.window.showErrorMessage('OpenMCP: ' + t('error.notOpenWorkspace'));
return;
}
const item = await acquireUserCustomConnection();
if (item.length === 0) {
return;
}
const workspaceConnectionConfig = getWorkspaceConnectionConfig();
workspaceConnectionConfig.items.push(item);
saveWorkspaceConnectionConfig(getWorkspacePath());
@ -83,10 +78,6 @@ export class McpWorkspaceConnectProvider implements vscode.TreeDataProvider<Conn
@RegisterCommand('openConfiguration')
public async openConfiguration(context: vscode.ExtensionContext, view: ConnectionViewItem) {
const configPath = getWorkspaceConnectionConfigPath();
if (!configPath) {
vscode.window.showErrorMessage('OpenMCP: ' + t('error.notOpenWorkspace'));
return;
}
const uri = vscode.Uri.file(configPath);
vscode.commands.executeCommand('vscode.open', uri);
}

View File

@ -1,8 +1,8 @@
import { getFirstValidPathFromCommand, getWorkspaceConnectionConfig, getWorkspacePath, McpOptions, panels, saveWorkspaceConnectionConfig } from "../global.js";
import * as vscode from 'vscode';
import { t } from "../i18n/index.js";
import { exec } from 'child_process';
import { promisify } from 'util';
import { spawn } from 'node:child_process';
export async function deleteUserConnection(item: McpOptions[] | McpOptions) {
// 弹出确认对话框
@ -22,10 +22,6 @@ export async function deleteUserConnection(item: McpOptions[] | McpOptions) {
}
const workspaceConnectionConfig = getWorkspaceConnectionConfig();
if (!workspaceConnectionConfig) {
vscode.window.showErrorMessage(t('error.notOpenWorkspace'));
return; // 没有打开工作区
}
// 从配置中移除该连接项
@ -50,6 +46,18 @@ export async function deleteUserConnection(item: McpOptions[] | McpOptions) {
}
}
export async function validateAndGetCommandPath(command: string, cwd?: string): Promise<string> {
const execAsync = promisify(exec);
try {
const { stdout } = await execAsync(`which ${command.split(' ')[0]}`, { cwd });
return stdout.trim();
} catch (error) {
throw new Error(`Cannot find command: ${command.split(' ')[0]}`);
}
}
export async function acquireUserCustomConnection(): Promise<McpOptions[]> {
// 让用户选择连接类型
const connectionType = await vscode.window.showQuickPick(['STDIO', 'SSE', 'STREAMABLE_HTTP'], {
@ -79,6 +87,14 @@ export async function acquireUserCustomConnection(): Promise<McpOptions[]> {
placeHolder: t('please-enter-cwd-placeholder')
});
// 校验 command + cwd 是否有效
try {
const commandPath = await validateAndGetCommandPath(commandString, cwd);
console.log('Command Path:', commandPath);
} catch (error) {
vscode.window.showErrorMessage(`Invalid command: ${error}`);
return [];
}
const commands = commandString.split(' ');
const command = commands[0];

View File

@ -10,12 +10,11 @@ suite('连接管理测试', () => {
let quickPickStub: sinon.SinonStub;
setup(async () => {
// mock选择连接类型
quickPickStub = sinon.stub(vscode.window, 'showQuickPick');
// mock 连接地址和认证输入框
inputBoxStub = sinon.stub(vscode.window, 'showInputBox');
// mock showQuickPick
// quickPickStub = sinon.stub(vscode.window, 'showQuickPick');
// // mock showInputBox
// inputBoxStub = sinon.stub(vscode.window, 'showInputBox');
await vscode.commands.executeCommand('workbench.view.extension.openmcp-sidebar');
deleteAllConnection();
});
@ -23,36 +22,18 @@ suite('连接管理测试', () => {
sinon.restore();
});
const deleteAllConnection = async () => {
//在开始之前删除所有链接
}
test('新建STDIO连接', async function () {
this.timeout(15000);
quickPickStub.onFirstCall().resolves('STDIO');
inputBoxStub.onFirstCall().resolves('echo'); // command
inputBoxStub.onSecondCall().resolves(''); // cwd
await vscode.commands.executeCommand('openmcp.sidebar.installed-connection.addConnection');
// await vscode.commands.executeCommand('openmcp.sidebar.workspace-connection.addConnection');
// quickPickStub.onFirstCall().resolves('STDIO');
// await new Promise(resolve => setTimeout(resolve, 5000));
// inputBoxStub.onFirstCall().resolves('echo'); // command
// await new Promise(resolve => setTimeout(resolve, 5000));
// inputBoxStub.onSecondCall().resolves(''); // cwd
await vscode.commands.executeCommand('openmcp.sidebar.workspace-connection.addConnection');
});
test('新建SSE连接', async function () {
quickPickStub.onFirstCall().resolves('SSE');
inputBoxStub.onFirstCall().resolves('http://localhost/sse'); // command
inputBoxStub.onSecondCall().resolves(''); // cwd
await vscode.commands.executeCommand('openmcp.sidebar.installed-connection.addConnection');
});
test('新建StreamableHttp连接', async function () {
quickPickStub.onFirstCall().resolves('STREAMABLE_HTTP');
inputBoxStub.onFirstCall().resolves('http://localhost/mcp'); // command
inputBoxStub.onSecondCall().resolves(''); // cwd
await vscode.commands.executeCommand('openmcp.sidebar.installed-connection.addConnection');
});
test('等待以便观察窗口', async function () {
this.timeout(15000);

View File

@ -105,12 +105,6 @@ export function revealOpenMcpWebviewPanel(
exportFile(data.filename, data.content);
break;
case 'vscode/openExternal':
if (data.url) {
vscode.env.openExternal(vscode.Uri.parse(data.url));
}
break;
case 'vscode/clipboard/writeText':
vscode.env.clipboard.writeText(data.text);
break;

1426
yarn.lock

File diff suppressed because it is too large Load Diff