fix(frontend): 汉化MCP工具展示并折叠原始说明
This commit is contained in:
@@ -219,3 +219,11 @@ Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,
|
||||
- Plugin/Skill/Provider 无记录时显示空状态,模型发现失败时允许使用真实的手动模型 ID。
|
||||
|
||||
验证:前端 81 项测试、类型检查与生产构建通过;后端 454 项测试通过。新增测试覆盖空初始状态、离线错误、真实统计与权限、测试 Provider 过滤、真实聊天历史及旧流隔离。本次未调用真实付费推理 API。
|
||||
|
||||
### MCP 工具中文展示补充
|
||||
|
||||
Agent 工具列表按 `mcp.<server_id>.<remote_name>` 的远程工具名匹配中文展示,支持 `web_search`(网页搜索)、`understand_image`(图像理解),并补充 `text.uppercase`(文本转大写)。此映射只影响界面,工具调用与权限选择仍使用完整原始 ID。
|
||||
|
||||
卡片默认显示三行摘要,完整服务原文可展开查看,展开操作不会改变工具选择。服务已提供中文说明时优先保留;未收录的 MCP 工具明确提示暂无中文说明,不将本地摘要当作服务协议或自动翻译结果。原始说明及其中的参数规则完整保留。
|
||||
|
||||
验证:前端 84 项测试、类型检查与生产构建通过。新增回归覆盖不同服务器命名空间、未知工具、服务中文说明、原文完整性,以及选择工具时保留原始 ID。
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useAgentStore } from '@/stores/agent'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
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 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.max_concurrent_tools" class="input" type="number" min="1" /></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>
|
||||
<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>
|
||||
@@ -114,12 +115,7 @@ function eventText(event: AgentEvent) {
|
||||
<style scoped>
|
||||
.agent-page > * { width: min(100%, 1080px); margin-inline: auto; }
|
||||
.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-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); }
|
||||
.tool-grid { display: grid; align-items: start; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
|
||||
.network { display: flex; gap: var(--space-sm); }
|
||||
.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); }
|
||||
|
||||
@@ -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'
|
||||
|
||||
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('转换运行状态和事件名称', () => {
|
||||
expect(runStatusLabel('waiting_permission')).toBe('等待授权')
|
||||
expect(eventLabel('ToolCall')).toBe('调用工具')
|
||||
|
||||
@@ -42,6 +42,7 @@ const toolLabels: Record<string, string> = {
|
||||
'tasks.list': '列出任务',
|
||||
'attachments.read': '读取附件',
|
||||
'audio.transcribe': '音频转写',
|
||||
'text.uppercase': '文本转大写',
|
||||
}
|
||||
|
||||
const toolDescriptions: Record<string, string> = {
|
||||
@@ -58,7 +59,25 @@ const toolDescriptions: Record<string, string> = {
|
||||
'tasks.update': '更新已有任务。',
|
||||
'tasks.list': '列出已持久化的任务。',
|
||||
'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> = {
|
||||
@@ -105,10 +124,17 @@ export function eventLabel(event: AgentEventType): string {
|
||||
}
|
||||
|
||||
export function toolLabel(name: string): string {
|
||||
const remote = mcpName(name)
|
||||
if (remote) return mcpTools[remote]?.label ?? `MCP 工具 · ${remote}`
|
||||
return toolLabels[name] ?? name
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user