This commit is contained in:
锦恢 2025-04-02 01:19:32 +08:00
parent f9ecdf233b
commit 2c643107ee
22 changed files with 576 additions and 22 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ dist
node_modules node_modules
.vscode-test/ .vscode-test/
*.vsix *.vsix
.env

View File

@ -1,27 +1,57 @@
<template> <template>
<div class="tools-module"> <div class="tool-module">
<h2>工具模块</h2> <div class="left">
<!-- 工具模块内容将在这里实现 --> <h2>
<span class="iconfont icon-tool"></span>
工具模块
</h2>
<h3><code>tools/list</code></h3>
<ToolList
:tab-id="props.tabId"
></ToolList>
</div>
<div class="right">
<ToolExecutor
:tab-id="props.tabId"
></ToolExecutor>
<ToolLogger
:tab-id="props.tabId"
></ToolLogger>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, defineProps } from 'vue'; import { defineProps } from 'vue';
import ToolList from './tool-list.vue';
defineComponent({ name: 'tool' }); import ToolExecutor from './tool-executor.vue';
import ToolLogger from './tool-logger.vue';
const props = defineProps({ const props = defineProps({
storage: { tabId: {
type: Object, type: Number,
required: true required: true
} }
}); });
</script> </script>
<style scoped> <style scoped>
.tools-module { .tool-module {
padding: 20px; padding: 20px;
height: 100%; height: 100%;
display: flex;
justify-content: space-around;
}
.tool-module .left {
width: 45%;
max-width: 410px;
}
.tool-module .right {
width: 45%;
} }
</style> </style>

View File

@ -0,0 +1,147 @@
<template>
<div>
<h3>{{ currentTool?.name }}</h3>
</div>
<div class="tool-executor-container">
<el-form :model="formData" :rules="formRules" ref="formRef" label-position="top">
<template v-if="currentTool?.inputSchema?.properties">
<el-scrollbar height="150px">
<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="formData[name]"
:placeholder="t('enter') + ' ' + (property.title || name)"
/>
<el-input-number
v-else-if="property.type === 'number' || property.type === 'integer'"
v-model="formData[name]"
controls-position="right"
:placeholder="t('enter') + ' ' + (property.title || name)"
/>
<el-switch
v-else-if="property.type === 'boolean'"
v-model="formData[name]"
/>
</el-form-item>
</el-scrollbar>
</template>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleExecute">
{{ t('execute-tool') }}
</el-button>
<el-button @click="resetForm">
{{ t('reset') }}
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { defineComponent, defineProps, watch, ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { FormInstance, FormRules } from 'element-plus';
import { tabs } from '../panel';
import { toolsManager, ToolStorage } from './tools';
import { CasualRestAPI, ToolCallResponse } from '@/hook/type';
import { useMessageBridge } from '@/api/message-bridge';
defineComponent({ name: 'tool-executor' });
const { t } = useI18n();
const props = defineProps({
tabId: {
type: Number,
required: true
}
});
const tab = tabs.content[props.tabId];
const tabStorage = tab.storage as ToolStorage;
const formRef = ref<FormInstance>();
const formData = ref<Record<string, any>>({});
const loading = ref(false);
const currentTool = computed(() => {
return toolsManager.tools.find(tool => tool.name === tabStorage.currentToolName);
});
const formRules = computed<FormRules>(() => {
const rules: FormRules = {};
if (!currentTool.value?.inputSchema?.properties) return rules;
Object.entries(currentTool.value.inputSchema.properties).forEach(([name, property]) => {
if (currentTool.value?.inputSchema?.required?.includes(name)) {
rules[name] = [
{
required: true,
message: `${property.title || name} 是必填字段`,
trigger: 'blur'
}
];
}
});
return rules;
});
const initFormData = () => {
formData.value = {};
if (!currentTool.value?.inputSchema?.properties) return;
Object.entries(currentTool.value.inputSchema.properties).forEach(([name, property]) => {
formData.value[name] = (property.type === 'number' || property.type === 'integer') ? 0 :
property.type === 'boolean' ? false : '';
});
};
const resetForm = () => {
formRef.value?.resetFields();
tabStorage.lastToolCallResponse = undefined;
};
function handleExecute() {
if (!currentTool.value) return;
const bridge = useMessageBridge();
bridge.addCommandListener('tools/call', (data: CasualRestAPI<ToolCallResponse>) => {
console.log(data.msg);
tabStorage.lastToolCallResponse = data.msg;
}, { once: true });
bridge.postMessage({
command: 'tools/call',
data: {
toolName: tabStorage.currentToolName,
toolArgs: formData.value
}
});
}
watch(() => tabStorage.currentToolName, () => {
initFormData();
resetForm();
}, { immediate: true });
</script>
<style>
.tool-executor-container {
background-color: var(--background);
padding: 10px 12px;
border-radius: .5em;
margin-bottom: 15px;
}
</style>

View File

@ -0,0 +1,154 @@
<template>
<div class="tool-list-container-scrollbar">
<el-scrollbar height="500px">
<div class="tool-list-container">
<div
class="item"
:class="{ 'active': tabStorage.currentToolName === tool.name }"
v-for="tool of toolsManager.tools"
:key="tool.name"
@click="handleClick(tool)"
>
<span>{{ tool.name }}</span>
<span>{{ tool.description || '' }}</span>
</div>
</div>
</el-scrollbar>
</div>
<div class="tool-list-function-container">
<el-button
type="primary"
@click="reloadTools({ first: false })"
>
{{ t('refresh') }}
</el-button>
</div>
</template>
<script setup lang="ts">
import { useMessageBridge } from '@/api/message-bridge';
import { CasualRestAPI, ToolsListResponse } from '@/hook/type';
import { onMounted, onUnmounted, defineProps } from 'vue';
import { useI18n } from 'vue-i18n';
import { toolsManager, ToolStorage } from './tools';
import { tabs } from '../panel';
import { ElMessage } from 'element-plus';
const bridge = useMessageBridge();
const { t } = useI18n();
const props = defineProps({
tabId: {
type: Number,
required: true
}
});
const tab = tabs.content[props.tabId];
const tabStorage = tab.storage as ToolStorage;
function reloadTools(option: { first: boolean }) {
bridge.postMessage({
command: 'tools/list'
});
if (!option.first) {
ElMessage({
message: t('finish-refresh'),
type: 'success',
duration: 3000,
showClose: true,
});
}
}
function handleClick(tool: { name: string }) {
tabStorage.currentToolName = tool.name;
tabStorage.lastToolCallResponse = undefined;
}
let commandCancel: (() => void);
onMounted(() => {
commandCancel = bridge.addCommandListener('tools/list', (data: CasualRestAPI<ToolsListResponse>) => {
toolsManager.tools = data.msg.tools || [];
if (toolsManager.tools.length > 0) {
tabStorage.currentToolName = toolsManager.tools[0].name;
tabStorage.lastToolCallResponse = undefined;
}
}, { once: false });
reloadTools({ first: true });
});
onUnmounted(() => {
if (commandCancel){
commandCancel();
}
})
</script>
<style>
.tool-list-container-scrollbar {
background-color: var(--background);
margin-bottom: 10px;
border-radius: .5em;
}
.tool-list-container {
height: fit-content;
display: flex;
flex-direction: column;
padding: 10px;
}
.tool-list-function-container {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.tool-list-function-container button {
width: 175px;
}
.tool-list-container > .item {
margin: 3px;
padding: 5px 10px;
border-radius: .3em;
user-select: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: var(--animation-3s);
}
.tool-list-container > .item:hover {
background-color: var(--main-light-color);
transition: var(--animation-3s);
}
.tool-list-container > .item.active {
background-color: var(--main-light-color);
transition: var(--animation-3s);
}
.tool-list-container > .item > span:first-child {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-list-container > .item > span:last-child {
opacity: 0.6;
font-size: 12.5px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<div class="tool-logger">
<span>
<span>{{ t('response') }}</span>
<span style="width: 200px;">
<el-switch
v-model="showRawJson"
inline-prompt
active-text="JSON"
inactive-text="Text"
style="margin-left: 10px; width: 200px;"
:inactive-action-style="'backgroundColor: var(--sidebar)'"
/>
</span>
</span>
<el-scrollbar height="350px">
<div
class="output-content"
contenteditable="false"
>
<template v-if="!showRawJson">
<template v-if="tabStorage.lastToolCallResponse?.isError">
<span style="color: var(--el-color-error)">
{{ tabStorage.lastToolCallResponse.content.map(c => c.text).join('\n') }}
</span>
</template>
<template v-else>
{{ tabStorage.lastToolCallResponse?.content.map(c => c.text).join('\n') }}
</template>
</template>
<template v-else>
{{ formattedJson }}
</template>
</div>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import { defineComponent, defineProps, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { tabs } from '../panel';
import { ToolStorage } from './tools';
defineComponent({ name: 'tool-logger' });
const { t } = useI18n();
const props = defineProps({
tabId: {
type: Number,
required: true
}
});
const tab = tabs.content[props.tabId];
const tabStorage = tab.storage as ToolStorage;
const showRawJson = ref(false);
const formattedJson = computed(() => {
try {
return JSON.stringify(tabStorage.lastToolCallResponse, null, 2);
} catch {
return 'Invalid JSON';
}
});
</script>
<style>
.tool-logger {
border-radius: .5em;
background-color: var(--background);
padding: 10px;
}
.tool-logger .el-switch__core {
border: 1px solid var(--main-color) !important;
width: 60px !important;
}
.tool-logger .el-switch .el-switch__action {
background-color: var(--main-color);
}
.tool-logger .el-switch.is-checked .el-switch__action {
background-color: var(--sidebar);
}
.tool-logger > span:first-child {
margin-bottom: 5px;
display: flex;
align-items: center;
}
.tool-logger .output-content {
border-radius: .5em;
padding: 15px;
min-height: 300px;
height: fit-content;
font-family: var(--code-font-family);
white-space: pre-wrap;
word-break: break-all;
user-select: text;
cursor: text;
font-size: 15px;
line-height: 1.5;
background-color: var(--sidebar);
}
</style>

View File

@ -0,0 +1,13 @@
import { ToolsListResponse, ToolCallResponse } from '@/hook/type';
import { reactive } from 'vue';
export const toolsManager = reactive<{
tools: ToolsListResponse['tools']
}>({
tools: []
});
export interface ToolStorage {
currentToolName: string;
lastToolCallResponse?: ToolCallResponse;
}

View File

@ -76,6 +76,16 @@ export interface PromptsGetResponse {
}>; }>;
} }
export interface ToolCallContent {
type: string;
text: string;
}
export interface ToolCallResponse {
content: ToolCallContent[];
isError: boolean;
}
// ==================== 请求接口定义 ==================== // ==================== 请求接口定义 ====================
export interface BaseRequest { export interface BaseRequest {
method: string; method: string;
@ -97,6 +107,17 @@ export interface PromptsGetRequest extends BaseRequest {
}; };
} }
export interface ToolCallRequest extends BaseRequest {
method: 'tools/call';
params: {
name: string;
arguments: Record<string, any>;
_meta?: {
progressToken?: number;
};
};
}
// ==================== 合并类型定义 ==================== // ==================== 合并类型定义 ====================
export type APIResponse = export type APIResponse =
| ToolsListResponse | ToolsListResponse
@ -104,9 +125,11 @@ export type APIResponse =
| ResourceTemplatesListResponse | ResourceTemplatesListResponse
| ResourcesListResponse | ResourcesListResponse
| ResourcesReadResponse | ResourcesReadResponse
| PromptsGetResponse; | PromptsGetResponse
| ToolCallResponse;
export type APIRequest = export type APIRequest =
| BaseRequest | BaseRequest
| ResourcesReadRequest | ResourcesReadRequest
| PromptsGetRequest; | PromptsGetRequest
| ToolCallRequest;

View File

@ -112,5 +112,6 @@
"refresh": "تحديث", "refresh": "تحديث",
"finish-refresh": "تم التحديث", "finish-refresh": "تم التحديث",
"response": "الاستجابة", "response": "الاستجابة",
"read-prompt": "استخراج الكلمات الرئيسية" "read-prompt": "استخراج الكلمات الرئيسية",
"execute-tool": "تشغيل الأداة"
} }

View File

@ -112,5 +112,6 @@
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"finish-refresh": "Aktualisierung abgeschlossen", "finish-refresh": "Aktualisierung abgeschlossen",
"response": "Antwort", "response": "Antwort",
"read-prompt": "Stichwörter extrahieren" "read-prompt": "Stichwörter extrahieren",
"execute-tool": "Werkzeug ausführen"
} }

View File

@ -112,5 +112,6 @@
"refresh": "Refresh", "refresh": "Refresh",
"finish-refresh": "Refresh completed", "finish-refresh": "Refresh completed",
"response": "Response", "response": "Response",
"read-prompt": "Extract keywords" "read-prompt": "Extract keywords",
"execute-tool": "Run tool"
} }

View File

@ -112,5 +112,6 @@
"refresh": "Rafraîchir", "refresh": "Rafraîchir",
"finish-refresh": "Actualisation terminée", "finish-refresh": "Actualisation terminée",
"response": "Réponse", "response": "Réponse",
"read-prompt": "Extraire des mots-clés" "read-prompt": "Extraire des mots-clés",
"execute-tool": "Exécuter l'outil"
} }

View File

@ -112,5 +112,6 @@
"refresh": "更新", "refresh": "更新",
"finish-refresh": "更新が完了しました", "finish-refresh": "更新が完了しました",
"response": "応答", "response": "応答",
"read-prompt": "キーワードを抽出" "read-prompt": "キーワードを抽出",
"execute-tool": "ツールを実行"
} }

View File

@ -112,5 +112,6 @@
"refresh": "새로 고침", "refresh": "새로 고침",
"finish-refresh": "새로 고침 완료", "finish-refresh": "새로 고침 완료",
"response": "응답", "response": "응답",
"read-prompt": "키워드 추출" "read-prompt": "키워드 추출",
"execute-tool": "도구 실행"
} }

View File

@ -112,5 +112,6 @@
"refresh": "Обновить", "refresh": "Обновить",
"finish-refresh": "Обновление завершено", "finish-refresh": "Обновление завершено",
"response": "Ответ", "response": "Ответ",
"read-prompt": "Извлечь ключевые слова" "read-prompt": "Извлечь ключевые слова",
"execute-tool": "Запустить инструмент"
} }

View File

@ -112,5 +112,6 @@
"refresh": "刷新", "refresh": "刷新",
"finish-refresh": "刷新完成", "finish-refresh": "刷新完成",
"response": "响应", "response": "响应",
"read-prompt": "提取提词" "read-prompt": "提取提词",
"execute-tool": "运行工具"
} }

View File

@ -112,5 +112,6 @@
"refresh": "重新整理", "refresh": "重新整理",
"finish-refresh": "刷新完成", "finish-refresh": "刷新完成",
"response": "響應", "response": "響應",
"read-prompt": "提取關鍵詞" "read-prompt": "提取關鍵詞",
"execute-tool": "執行工具"
} }

1
test/.gitignore vendored
View File

@ -21,3 +21,4 @@ pnpm-debug.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
config.json

View File

@ -105,6 +105,11 @@ export class MCPClient {
}); });
} }
// 列出所有工具
public async listTools() {
return await this.client.listTools();
}
// 调用工具 // 调用工具
public async callTool(options: { name: string; arguments: Record<string, any> }) { public async callTool(options: { name: string; arguments: Record<string, any> }) {
return await this.client.callTool(options); return await this.client.callTool(options);

View File

@ -182,6 +182,41 @@ export async function readResource(
} }
} }
/**
* @description
*/
export async function listTools(
client: MCPClient | undefined,
webview: VSCodeWebViewLike
) {
if (!client) {
const connectResult = {
code: 501,
msg: 'mcp client 尚未连接'
};
webview.postMessage({ command: 'tools/list', data: connectResult });
return;
}
try {
const tools = await client.listTools();
const result = {
code: 200,
msg: tools
};
webview.postMessage({ command: 'tools/list', data: result });
} catch (error) {
const result = {
code: 500,
msg: (error as any).toString()
};
webview.postMessage({ command: 'tools/list', data: result });
}
}
/** /**
* @description * @description
*/ */
@ -204,6 +239,7 @@ export async function callTool(
name: option.toolName, name: option.toolName,
arguments: option.toolArgs arguments: option.toolArgs
}); });
const result = { const result = {
code: 200, code: 200,
msg: toolResult msg: toolResult

View File

@ -1,7 +1,7 @@
import { VSCodeWebViewLike } from '../adapter'; import { VSCodeWebViewLike } from '../adapter';
import { connect, MCPClient, type MCPOptions } from './connect'; import { connect, MCPClient, type MCPOptions } from './connect';
import { callTool, getPrompt, listPrompts, listResources, listResourceTemplates, readResource } from './handler'; import { callTool, getPrompt, listPrompts, listResources, listResourceTemplates, listTools, readResource } from './handler';
import { ping } from './util'; import { ping } from './util';
@ -57,6 +57,10 @@ export function messageController(command: string, data: any, webview: VSCodeWeb
readResource(client, data, webview); readResource(client, data, webview);
break; break;
case 'tools/list':
listTools(client, webview);
break;
case 'tools/call': case 'tools/call':
callTool(client, data, webview); callTool(client, data, webview);
break; break;

View File

@ -67,6 +67,16 @@ export interface PromptsGetResponse {
}>; }>;
} }
export interface ToolListItem {
name: string;
description: string;
inputSchema: InputSchema;
}
export interface ToolsListResponse {
tools: ToolListItem[];
}
// ==================== 请求接口定义 ==================== // ==================== 请求接口定义 ====================
export interface BaseRequest { export interface BaseRequest {
method: string; method: string;

12
test/src/util.ts Normal file
View File

@ -0,0 +1,12 @@
function getConfigurationPath() {
// 如果是 vscode 插件下,则修改为 ~/.openmcp/config.json
return 'config.json';
}
export function loadConfig() {
}
export function saveConfig() {
}