fix(frontend): 汉化MCP工具展示并折叠原始说明

This commit is contained in:
2026-09-04 07:52:53 +08:00
parent c04f4c1989
commit 9f621371b8
6 changed files with 113 additions and 9 deletions
@@ -219,3 +219,11 @@ Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB
- Plugin/Skill/Provider 无记录时显示空状态,模型发现失败时允许使用真实的手动模型 ID。 - Plugin/Skill/Provider 无记录时显示空状态,模型发现失败时允许使用真实的手动模型 ID。
验证:前端 81 项测试、类型检查与生产构建通过;后端 454 项测试通过。新增测试覆盖空初始状态、离线错误、真实统计与权限、测试 Provider 过滤、真实聊天历史及旧流隔离。本次未调用真实付费推理 API。 验证:前端 81 项测试、类型检查与生产构建通过;后端 454 项测试通过。新增测试覆盖空初始状态、离线错误、真实统计与权限、测试 Provider 过滤、真实聊天历史及旧流隔离。本次未调用真实付费推理 API。
### MCP 工具中文展示补充
Agent 工具列表按 `mcp.<server_id>.<remote_name>` 的远程工具名匹配中文展示,支持 `web_search`(网页搜索)、`understand_image`(图像理解),并补充 `text.uppercase`(文本转大写)。此映射只影响界面,工具调用与权限选择仍使用完整原始 ID。
卡片默认显示三行摘要,完整服务原文可展开查看,展开操作不会改变工具选择。服务已提供中文说明时优先保留;未收录的 MCP 工具明确提示暂无中文说明,不将本地摘要当作服务协议或自动翻译结果。原始说明及其中的参数规则完整保留。
验证:前端 84 项测试、类型检查与生产构建通过。新增回归覆盖不同服务器命名空间、未知工具、服务中文说明、原文完整性,以及选择工具时保留原始 ID。
+4 -8
View File
@@ -5,7 +5,8 @@ import { useAgentStore } from '@/stores/agent'
import { useProviderStore } from '@/stores/provider' import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill' import { useSkillStore } from '@/stores/skill'
import type { AgentEvent } from '@/contracts' import type { AgentEvent } from '@/contracts'
import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolDescription, toolLabel } from './labels' import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolLabel } from './labels'
import ToolOption from './ToolOption.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -88,7 +89,7 @@ function eventText(event: AgentEvent) {
<div class="field"><label>令牌预算</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div> <div class="field"><label>令牌预算</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div>
<div class="field"><label>最大并发工具</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div> <div class="field"><label>最大并发工具</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
</div> </div>
<div class="field"><label>允许使用的工具</label><div class="tool-grid"><label v-for="tool in agentStore.tools" :key="tool.name" class="tool-option"><input type="checkbox" :checked="form.allowed_tools.includes(tool.name)" @change="toggleTool(tool.name)" /><span><strong>{{ toolLabel(tool.name) }}</strong><code>{{ tool.name }}</code><small>{{ toolDescription(tool.name, tool.description) }}</small></span></label></div></div> <div class="field"><label>允许使用的工具</label><div class="tool-grid"><ToolOption v-for="tool in agentStore.tools" :key="tool.name" :name="tool.name" :description="tool.description" :selected="form.allowed_tools.includes(tool.name)" @toggle="toggleTool" /></div></div>
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次运行调用网络工具</label> <label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次运行调用网络工具</label>
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim() || !form.provider_id || !form.model.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div> <div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim() || !form.provider_id || !form.model.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div>
</form> </form>
@@ -114,12 +115,7 @@ function eventText(event: AgentEvent) {
<style scoped> <style scoped>
.agent-page > * { width: min(100%, 1080px); margin-inline: auto; } .agent-page > * { width: min(100%, 1080px); margin-inline: auto; }
.run-form { display: grid; gap: var(--space-xl); } .run-form { display: grid; gap: var(--space-xl); }
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); } .tool-grid { display: grid; align-items: start; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
.tool-option { display: flex; gap: var(--space-sm); padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); cursor: pointer; transition: border-color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast), box-shadow var(--motion-fast); }
.tool-option:hover { border-color: var(--color-accent-secondary); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
.tool-option:has(input:checked) { border-color: var(--color-accent-primary); background: var(--color-accent-soft); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-primary) 10%, transparent); }
.tool-option small { display: block; color: var(--color-text-secondary); }
.tool-option code { display: block; margin: 2px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.network { display: flex; gap: var(--space-sm); } .network { display: flex; gap: var(--space-sm); }
.trace-layout { display: grid; gap: var(--space-lg); } .trace-layout { display: grid; gap: var(--space-lg); }
.run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); } .run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
@@ -0,0 +1,19 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import ToolOption from './ToolOption.vue'
it('shows Chinese summaries, preserves raw metadata and emits the original tool ID', async () => {
const name = 'mcp.9ca7ee21603a.web_search'
const description = 'Search the web. query: string. ' + 'Full provider instructions. '.repeat(40)
const wrapper = mount(ToolOption, { props: { name, description, selected: false } })
expect(wrapper.get('strong').text()).toBe('网页搜索')
expect(wrapper.get('code').text()).toBe(name)
expect(wrapper.get('.tool-summary').text()).toContain('搜索关键词')
expect(wrapper.get('details').attributes('open')).toBeUndefined()
expect(wrapper.get('details p').element.textContent).toBe(description)
await wrapper.get('summary').trigger('click')
expect(wrapper.emitted('toggle')).toBeUndefined()
await wrapper.get('input').setValue(true)
expect(wrapper.emitted('toggle')).toEqual([[name]])
})
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toolDescription, toolLabel } from './labels'
const props = defineProps<{ name: string; description: string; selected: boolean }>()
const emit = defineEmits<{ toggle: [name: string] }>()
const summary = computed(() => toolDescription(props.name, props.description))
const showOriginal = computed(() => props.description.length > 0)
</script>
<template>
<article class="tool-choice" :class="{ selected }">
<label class="tool-selection">
<input type="checkbox" :checked="selected" @change="emit('toggle', name)" />
<span class="tool-copy">
<strong>{{ toolLabel(name) }}</strong>
<code>{{ name }}</code>
<small class="tool-summary">{{ summary }}</small>
</span>
</label>
<details v-if="showOriginal" class="tool-original">
<summary>查看服务原文与参数</summary>
<p>{{ description }}</p>
</details>
</article>
</template>
<style scoped>
.tool-choice { min-width: 0; padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); }
.tool-choice.selected { border-color: var(--color-accent-primary); background: var(--color-accent-soft); }
.tool-selection { display: flex; align-items: flex-start; gap: var(--space-sm); cursor: pointer; }
.tool-selection input { flex-shrink: 0; margin-top: 4px; }
.tool-copy { min-width: 0; overflow-wrap: anywhere; }
.tool-copy strong, .tool-copy code, .tool-summary { display: block; }
.tool-copy code { margin: 3px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.tool-summary { color: var(--color-text-secondary); line-height: 1.6; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
.tool-original { margin-top: var(--space-sm); font-size: var(--font-size-xs); }
.tool-original summary { cursor: pointer; color: var(--color-text-secondary); }
.tool-original p { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 240px; overflow: auto; margin-top: var(--space-sm); user-select: text; }
</style>
@@ -9,6 +9,21 @@ import {
} from './labels' } from './labels'
describe('智能体页面中文标签', () => { describe('智能体页面中文标签', () => {
it('按 MCP 远程工具名匹配中文,不依赖服务器 ID', () => {
for (const server of ['9ca7ee21603a', 'another-server']) {
expect(toolLabel(`mcp.${server}.web_search`)).toBe('网页搜索')
expect(toolLabel(`mcp.${server}.understand_image`)).toBe('图像理解')
expect(toolDescription(`mcp.${server}.web_search`, 'Search the web')).toContain('搜索关键词')
}
expect(toolLabel('text.uppercase')).toBe('文本转大写')
expect(toolDescription('text.uppercase', 'Convert input text to uppercase.')).toContain('大写')
})
it('保留服务端中文,未知工具不编造翻译或套用内置工具语义', () => {
expect(toolDescription('mcp.server.web_search', '仅搜索指定站点。')).toBe('仅搜索指定站点。')
expect(toolDescription('mcp.server.custom_action', 'Private action')).toContain('暂无中文说明')
expect(toolLabel('mcp.server.notes.delete')).toBe('MCP 工具 · notes.delete')
})
it('转换运行状态和事件名称', () => { it('转换运行状态和事件名称', () => {
expect(runStatusLabel('waiting_permission')).toBe('等待授权') expect(runStatusLabel('waiting_permission')).toBe('等待授权')
expect(eventLabel('ToolCall')).toBe('调用工具') expect(eventLabel('ToolCall')).toBe('调用工具')
+27 -1
View File
@@ -42,6 +42,7 @@ const toolLabels: Record<string, string> = {
'tasks.list': '列出任务', 'tasks.list': '列出任务',
'attachments.read': '读取附件', 'attachments.read': '读取附件',
'audio.transcribe': '音频转写', 'audio.transcribe': '音频转写',
'text.uppercase': '文本转大写',
} }
const toolDescriptions: Record<string, string> = { const toolDescriptions: Record<string, string> = {
@@ -58,7 +59,25 @@ const toolDescriptions: Record<string, string> = {
'tasks.update': '更新已有任务。', 'tasks.update': '更新已有任务。',
'tasks.list': '列出已持久化的任务。', 'tasks.list': '列出已持久化的任务。',
'attachments.read': '读取由宿主管理的 UTF-8 附件。', 'attachments.read': '读取由宿主管理的 UTF-8 附件。',
'audio.transcribe': '读取音频附件已有的宿主转写结果。', 'audio.transcribe': '将音频转写为文本,按模型路由使用 API 或本地后端。',
'text.uppercase': '将输入文本中的字母转换为大写。',
}
// MCP IDs contain a server-specific namespace. Localize the remote tool name
// for presentation only; requests must keep using the complete original ID.
const mcpTools: Record<string, { label: string; description: string }> = {
web_search: {
label: '网页搜索',
description: '搜索实时或外部网页信息。输入搜索关键词;结果包含标题、链接、摘要等信息。时效性问题可在关键词中加入日期,完整参数以服务原文为准。',
},
understand_image: {
label: '图像理解',
description: '根据提示词分析图片、描述内容或提取信息。输入分析要求和图片地址或本地路径;支持的格式与路径规则请查看服务原文。',
},
}
function mcpName(name: string): string | undefined {
return /^mcp\.[^.]+\.(.+)$/.exec(name)?.[1]
} }
const permissionLabels: Record<string, string> = { const permissionLabels: Record<string, string> = {
@@ -105,10 +124,17 @@ export function eventLabel(event: AgentEventType): string {
} }
export function toolLabel(name: string): string { export function toolLabel(name: string): string {
const remote = mcpName(name)
if (remote) return mcpTools[remote]?.label ?? `MCP 工具 · ${remote}`
return toolLabels[name] ?? name return toolLabels[name] ?? name
} }
export function toolDescription(name: string, fallback: string): string { export function toolDescription(name: string, fallback: string): string {
const remote = mcpName(name)
if (remote) {
if (/\p{Script=Han}/u.test(fallback)) return fallback
return mcpTools[remote]?.description ?? '暂无中文说明,请展开查看服务原文。'
}
return toolDescriptions[name] ?? fallback return toolDescriptions[name] ?? fallback
} }