@steerable/agent-shell-web 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +20 -0
  3. package/package.json +69 -0
  4. package/public/favicon.ico +0 -0
  5. package/public/favicon.png +0 -0
  6. package/src/AppShell.tsx +24 -0
  7. package/src/assets/logo-generic.png +0 -0
  8. package/src/brand.test.ts +22 -0
  9. package/src/brand.ts +48 -0
  10. package/src/components/AgentSidebar.test.tsx +127 -0
  11. package/src/components/AgentSidebar.tsx +1010 -0
  12. package/src/components/ChatHeader.test.tsx +230 -0
  13. package/src/components/ChatHeader.tsx +436 -0
  14. package/src/components/DockPanelHeader.tsx +69 -0
  15. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  16. package/src/components/LocalLlmSettingsModal.tsx +72 -0
  17. package/src/components/TerminalPanel.tsx +68 -0
  18. package/src/components/TerminalView.tsx +228 -0
  19. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  20. package/src/components/chat/ApprovalModal.tsx +229 -0
  21. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  22. package/src/components/chat/AskUserModalHost.tsx +61 -0
  23. package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
  24. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  25. package/src/components/chat/AssistantMessage.tsx +469 -0
  26. package/src/components/chat/ChatInput.test.tsx +609 -0
  27. package/src/components/chat/ChatInput.tsx +2068 -0
  28. package/src/components/chat/ChatProjectBadge.tsx +362 -0
  29. package/src/components/chat/EmptyChat.tsx +18 -0
  30. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  31. package/src/components/chat/ExecPolicyPicker.tsx +120 -0
  32. package/src/components/chat/ExecutedActionsCard.tsx +216 -0
  33. package/src/components/chat/InterruptedTurnCard.tsx +53 -0
  34. package/src/components/chat/LocalChatPanel.tsx +408 -0
  35. package/src/components/chat/Markdown.test.tsx +170 -0
  36. package/src/components/chat/Markdown.tsx +432 -0
  37. package/src/components/chat/MessageList.tsx +332 -0
  38. package/src/components/chat/ModelPicker.test.tsx +149 -0
  39. package/src/components/chat/ModelPicker.tsx +289 -0
  40. package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
  41. package/src/components/chat/PlanTodoList.tsx +121 -0
  42. package/src/components/chat/ProjectTrustBanner.tsx +91 -0
  43. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  44. package/src/components/chat/SessionTreeModal.tsx +221 -0
  45. package/src/components/chat/StreamingStatus.tsx +95 -0
  46. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  47. package/src/components/chat/TaskOutcomeCards.tsx +128 -0
  48. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  49. package/src/components/chat/TaskPanelModal.tsx +310 -0
  50. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  51. package/src/components/chat/TaskProcessPanel.tsx +208 -0
  52. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  53. package/src/components/chat/TurnProcessGroup.tsx +193 -0
  54. package/src/components/chat/UnifiedDiff.tsx +31 -0
  55. package/src/components/chat/UserMessage.tsx +155 -0
  56. package/src/components/chat/elapsed.test.ts +42 -0
  57. package/src/components/chat/elapsed.ts +45 -0
  58. package/src/components/chat/executed-actions-model.test.ts +134 -0
  59. package/src/components/chat/executed-actions-model.ts +143 -0
  60. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  61. package/src/components/chat/orchestration-children-model.ts +104 -0
  62. package/src/components/chat/timestamp.ts +24 -0
  63. package/src/components/chat/turn-timeline.test.ts +97 -0
  64. package/src/components/chat/turn-timeline.ts +132 -0
  65. package/src/components/chat/useChatTasks.test.ts +136 -0
  66. package/src/components/chat/useChatTasks.ts +134 -0
  67. package/src/components/chat/useCopy.ts +62 -0
  68. package/src/components/settings/AgentCapabilityFields.tsx +343 -0
  69. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  70. package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
  71. package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
  72. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  73. package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
  74. package/src/components/settings/LlmSettingsPanel.tsx +875 -0
  75. package/src/components/settings/McpSettingsPanel.tsx +466 -0
  76. package/src/components/settings/ModelCapabilityChips.tsx +46 -0
  77. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  78. package/src/components/settings/ModelIdCombobox.tsx +185 -0
  79. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  80. package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
  81. package/src/components/settings/SettingsSaveButton.tsx +35 -0
  82. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  83. package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
  84. package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
  85. package/src/components/settings/UsagePanel.tsx +196 -0
  86. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  87. package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
  88. package/src/components/settings/compat-flags-model.test.ts +71 -0
  89. package/src/components/settings/compat-flags-model.ts +57 -0
  90. package/src/components/settings/llm-vendors.test.ts +171 -0
  91. package/src/components/settings/llm-vendors.ts +275 -0
  92. package/src/components/settings/model-capabilities.test.ts +88 -0
  93. package/src/components/settings/model-capabilities.ts +123 -0
  94. package/src/components/settings/preset-choice-model.test.ts +96 -0
  95. package/src/components/settings/preset-choice-model.ts +75 -0
  96. package/src/fixtures/browser-dev-data.json +315 -0
  97. package/src/hooks/useChatsAndAgents.ts +182 -0
  98. package/src/layouts/AgentLayout.tsx +574 -0
  99. package/src/lib/attachments.ts +78 -0
  100. package/src/lib/browser-dev-electron-mock.ts +640 -0
  101. package/src/lib/chat-title.ts +33 -0
  102. package/src/lib/chat-transport.ts +346 -0
  103. package/src/lib/date-groups.ts +34 -0
  104. package/src/lib/demo-flag.ts +15 -0
  105. package/src/lib/electron-bridge.ts +270 -0
  106. package/src/lib/exec-policy.ts +25 -0
  107. package/src/lib/http-bridge.ts +293 -0
  108. package/src/lib/insights.ts +17 -0
  109. package/src/lib/local-api.ts +714 -0
  110. package/src/lib/pending-first-message.test.ts +17 -0
  111. package/src/lib/pending-first-message.ts +36 -0
  112. package/src/lib/slash-sources.ts +191 -0
  113. package/src/main.tsx +103 -0
  114. package/src/packs/registry.ts +64 -0
  115. package/src/pages/AgentPage.tsx +1226 -0
  116. package/src/pages/ChatPanelPreviewPage.tsx +264 -0
  117. package/src/pages/SettingsPage.test.tsx +102 -0
  118. package/src/pages/SettingsPage.tsx +197 -0
  119. package/src/styles/index.css +156 -0
  120. package/src/vite-env.d.ts +1 -0
  121. package/vite.base.ts +94 -0
@@ -0,0 +1,466 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ LuLoaderCircle,
4
+ LuPencil,
5
+ LuPlug,
6
+ LuPlus,
7
+ LuRefreshCw,
8
+ LuToggleLeft,
9
+ LuToggleRight,
10
+ LuTrash2,
11
+ } from 'react-icons/lu';
12
+ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
13
+
14
+ /**
15
+ * McpSettingsPanel — MCP 服务管理面板(JSON 导入 / 手动增改 / 启停 / 测试 /
16
+ * 删除)。
17
+ *
18
+ * 渲染在 `/settings?section=mcp` 独立页(AgentLayout 右侧内容区)。
19
+ *
20
+ * 数据与状态完全自管理:挂载时拉一次列表,操作后刷新。后端走
21
+ * local-backend REST(`/api/v2/mcp/servers*`),注册表持久化在 main 进程的
22
+ * electron-store(`agent-mcp-servers.json`),无专用 IPC channel。
23
+ */
24
+
25
+ /** GET /api/v2/mcp/servers 返回的服务条目(含工具缓存状态)。 */
26
+ export interface McpServerInfo {
27
+ id: string;
28
+ name: string;
29
+ command: string;
30
+ args: string[];
31
+ env: Record<string, string>;
32
+ cwd?: string;
33
+ enabled: boolean;
34
+ serverKey: string;
35
+ toolCount: number;
36
+ toolsPreview: string[];
37
+ lastError: string | null;
38
+ lastFetchedAt: string | null;
39
+ }
40
+
41
+ export function McpSettingsPanel() {
42
+ const [mcpServers, setMcpServers] = useState<McpServerInfo[]>([]);
43
+ const [mcpLoading, setMcpLoading] = useState(false);
44
+ const [mcpImportJson, setMcpImportJson] = useState('');
45
+ const [mcpImporting, setMcpImporting] = useState(false);
46
+ const [mcpImportStatus, setMcpImportStatus] = useState<string | null>(null);
47
+ const [mcpFormOpen, setMcpFormOpen] = useState(false);
48
+ const [mcpEditingId, setMcpEditingId] = useState<string | null>(null);
49
+ const [mcpFormName, setMcpFormName] = useState('');
50
+ const [mcpFormCommand, setMcpFormCommand] = useState('');
51
+ const [mcpFormArgs, setMcpFormArgs] = useState('');
52
+ const [mcpFormEnv, setMcpFormEnv] = useState('');
53
+ const [mcpFormCwd, setMcpFormCwd] = useState('');
54
+ const [mcpSaving, setMcpSaving] = useState(false);
55
+ const [mcpTestingId, setMcpTestingId] = useState<string | null>(null);
56
+ const [mcpDeletingId, setMcpDeletingId] = useState<string | null>(null);
57
+ const [error, setError] = useState<string | null>(null);
58
+
59
+ const fetchMcpServers = useCallback(async () => {
60
+ if (!isElectron()) return;
61
+ setMcpLoading(true);
62
+ setError(null);
63
+ try {
64
+ const res = await getElectronBridge()!.localBackend.request<{ servers: McpServerInfo[] }>({
65
+ method: 'GET',
66
+ path: '/api/v2/mcp/servers',
67
+ });
68
+ setMcpServers(res.servers || []);
69
+ } catch (err) {
70
+ console.error('获取 MCP 服务列表失败:', err);
71
+ setError(err instanceof Error ? err.message : String(err));
72
+ } finally {
73
+ setMcpLoading(false);
74
+ }
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ void fetchMcpServers();
79
+ }, [fetchMcpServers]);
80
+
81
+ const resetMcpForm = () => {
82
+ setMcpEditingId(null);
83
+ setMcpFormName('');
84
+ setMcpFormCommand('');
85
+ setMcpFormArgs('');
86
+ setMcpFormEnv('');
87
+ setMcpFormCwd('');
88
+ };
89
+
90
+ const parseEnvLines = (raw: string): Record<string, string> => {
91
+ const env: Record<string, string> = {};
92
+ for (const line of raw.split('\n')) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed || trimmed.startsWith('#')) continue;
95
+ const eq = trimmed.indexOf('=');
96
+ if (eq <= 0) continue;
97
+ env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
98
+ }
99
+ return env;
100
+ };
101
+
102
+ const handleSaveMcpServer = async () => {
103
+ if (!isElectron()) return;
104
+ setMcpSaving(true);
105
+ setError(null);
106
+ try {
107
+ const body = {
108
+ name: mcpFormName.trim(),
109
+ command: mcpFormCommand.trim(),
110
+ args: mcpFormArgs.split('\n').map((s) => s.trim()).filter(Boolean),
111
+ env: parseEnvLines(mcpFormEnv),
112
+ cwd: mcpFormCwd.trim() || undefined,
113
+ };
114
+ if (mcpEditingId) {
115
+ await getElectronBridge()!.localBackend.request({
116
+ method: 'PUT',
117
+ path: `/api/v2/mcp/servers/${encodeURIComponent(mcpEditingId)}`,
118
+ body,
119
+ });
120
+ } else {
121
+ await getElectronBridge()!.localBackend.request({
122
+ method: 'POST',
123
+ path: '/api/v2/mcp/servers',
124
+ body,
125
+ });
126
+ }
127
+ resetMcpForm();
128
+ setMcpFormOpen(false);
129
+ await fetchMcpServers();
130
+ } catch (err) {
131
+ setError(err instanceof Error ? err.message : String(err));
132
+ } finally {
133
+ setMcpSaving(false);
134
+ }
135
+ };
136
+
137
+ const handleEditMcpServer = (server: McpServerInfo) => {
138
+ setMcpEditingId(server.id);
139
+ setMcpFormName(server.name);
140
+ setMcpFormCommand(server.command);
141
+ setMcpFormArgs((server.args || []).join('\n'));
142
+ setMcpFormEnv(
143
+ Object.entries(server.env || {})
144
+ .map(([k, v]) => `${k}=${v}`)
145
+ .join('\n'),
146
+ );
147
+ setMcpFormCwd(server.cwd || '');
148
+ setMcpFormOpen(true);
149
+ };
150
+
151
+ const handleToggleMcpServer = async (server: McpServerInfo) => {
152
+ setError(null);
153
+ try {
154
+ await getElectronBridge()!.localBackend.request({
155
+ method: 'PUT',
156
+ path: `/api/v2/mcp/servers/${encodeURIComponent(server.id)}`,
157
+ body: { enabled: !server.enabled },
158
+ });
159
+ await fetchMcpServers();
160
+ } catch (err) {
161
+ setError(err instanceof Error ? err.message : String(err));
162
+ }
163
+ };
164
+
165
+ const handleDeleteMcpServer = async (server: McpServerInfo) => {
166
+ setMcpDeletingId(server.id);
167
+ setError(null);
168
+ try {
169
+ await getElectronBridge()!.localBackend.request({
170
+ method: 'DELETE',
171
+ path: `/api/v2/mcp/servers/${encodeURIComponent(server.id)}`,
172
+ });
173
+ await fetchMcpServers();
174
+ } catch (err) {
175
+ setError(err instanceof Error ? err.message : String(err));
176
+ } finally {
177
+ setMcpDeletingId(null);
178
+ }
179
+ };
180
+
181
+ const handleTestMcpServer = async (server: McpServerInfo) => {
182
+ setMcpTestingId(server.id);
183
+ setError(null);
184
+ try {
185
+ const res = await getElectronBridge()!.localBackend.request<{
186
+ success: boolean;
187
+ toolCount: number;
188
+ tools: { name: string }[];
189
+ error: string | null;
190
+ }>({
191
+ method: 'POST',
192
+ path: `/api/v2/mcp/servers/${encodeURIComponent(server.id)}/test`,
193
+ });
194
+ if (!res.success && res.error) {
195
+ setError(`「${server.name}」连接失败:${res.error}`);
196
+ }
197
+ await fetchMcpServers();
198
+ } catch (err) {
199
+ setError(err instanceof Error ? err.message : String(err));
200
+ } finally {
201
+ setMcpTestingId(null);
202
+ }
203
+ };
204
+
205
+ const handleImportMcpJson = async () => {
206
+ const json = mcpImportJson.trim();
207
+ if (!json) return;
208
+ setMcpImporting(true);
209
+ setError(null);
210
+ try {
211
+ const res = await getElectronBridge()!.localBackend.request<{
212
+ added: { name: string }[];
213
+ skipped: string[];
214
+ }>({
215
+ method: 'POST',
216
+ path: '/api/v2/mcp/servers/import',
217
+ body: { json },
218
+ });
219
+ const parts: string[] = [];
220
+ if (res.added.length > 0) parts.push(`已导入 ${res.added.length} 个:${res.added.map((s) => s.name).join('、')}`);
221
+ if (res.skipped.length > 0) parts.push(`跳过 ${res.skipped.length} 个(重名或缺 command):${res.skipped.join('、')}`);
222
+ setMcpImportStatus(parts.join(';') || '没有可导入的服务');
223
+ if (res.added.length > 0) setMcpImportJson('');
224
+ await fetchMcpServers();
225
+ } catch (err) {
226
+ setMcpImportStatus(err instanceof Error ? err.message : String(err));
227
+ } finally {
228
+ setMcpImporting(false);
229
+ }
230
+ };
231
+
232
+ return (
233
+ <div className="space-y-4">
234
+ {/* JSON 导入 */}
235
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-2">
236
+ <h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
237
+ <LuPlug className="h-3.5 w-3.5 text-agent-muted-foreground" />
238
+ 粘贴 JSON 导入 (Claude Desktop 格式)
239
+ </h4>
240
+ <textarea
241
+ value={mcpImportJson}
242
+ onChange={(e) => setMcpImportJson(e.target.value)}
243
+ placeholder={'{\n "mcpServers": {\n "filesystem": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "C:/"]\n }\n }\n}'}
244
+ rows={5}
245
+ disabled={mcpImporting}
246
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
247
+ />
248
+ <div className="flex items-center justify-between gap-2">
249
+ <p className="text-[10px] text-agent-muted-foreground">
250
+ 与 Claude Desktop / Cursor 的 mcpServers 配置格式一致,可整段粘贴。
251
+ </p>
252
+ <button
253
+ type="button"
254
+ onClick={handleImportMcpJson}
255
+ disabled={mcpImporting || !mcpImportJson.trim()}
256
+ className={`h-8 shrink-0 px-4 rounded-full text-xs font-medium transition-all ${
257
+ mcpImporting || !mcpImportJson.trim()
258
+ ? 'bg-agent-muted text-agent-muted-foreground cursor-not-allowed'
259
+ : 'bg-agent-foreground text-agent-canvas hover:opacity-90'
260
+ }`}
261
+ >
262
+ {mcpImporting ? <LuLoaderCircle className="h-3 w-3 animate-spin" /> : '导入'}
263
+ </button>
264
+ </div>
265
+ {mcpImportStatus && (
266
+ <p className="text-[10px] text-agent-muted-foreground bg-agent-muted/10 px-2 py-1 rounded border border-agent-border/20">
267
+ {mcpImportStatus}
268
+ </p>
269
+ )}
270
+ </div>
271
+
272
+ {/* 手动添加 / 编辑表单 */}
273
+ {mcpFormOpen ? (
274
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-2.5">
275
+ <h4 className="text-xs font-semibold text-agent-foreground">
276
+ {mcpEditingId ? '编辑 MCP 服务' : '手动添加 MCP 服务'}
277
+ </h4>
278
+ <div className="grid grid-cols-2 gap-2">
279
+ <input
280
+ type="text"
281
+ value={mcpFormName}
282
+ onChange={(e) => setMcpFormName(e.target.value)}
283
+ placeholder="名称,如 filesystem"
284
+ className="h-8 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
285
+ />
286
+ <input
287
+ type="text"
288
+ value={mcpFormCommand}
289
+ onChange={(e) => setMcpFormCommand(e.target.value)}
290
+ placeholder="启动命令,如 npx / uvx / node"
291
+ className="h-8 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
292
+ />
293
+ </div>
294
+ <textarea
295
+ value={mcpFormArgs}
296
+ onChange={(e) => setMcpFormArgs(e.target.value)}
297
+ placeholder="参数(每行一个),如:&#10;-y&#10;@modelcontextprotocol/server-filesystem&#10;C:/"
298
+ rows={3}
299
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
300
+ />
301
+ <textarea
302
+ value={mcpFormEnv}
303
+ onChange={(e) => setMcpFormEnv(e.target.value)}
304
+ placeholder="环境变量(每行一个 KEY=VALUE,可留空)"
305
+ rows={2}
306
+ className="w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 py-2 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
307
+ />
308
+ <input
309
+ type="text"
310
+ value={mcpFormCwd}
311
+ onChange={(e) => setMcpFormCwd(e.target.value)}
312
+ placeholder="工作目录(可选)"
313
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
314
+ />
315
+ <div className="flex justify-end gap-2">
316
+ <button
317
+ type="button"
318
+ onClick={() => { resetMcpForm(); setMcpFormOpen(false); }}
319
+ className="h-8 rounded-full px-4 text-xs font-medium text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground"
320
+ >
321
+ 取消
322
+ </button>
323
+ <button
324
+ type="button"
325
+ onClick={handleSaveMcpServer}
326
+ disabled={mcpSaving || !mcpFormName.trim() || !mcpFormCommand.trim()}
327
+ className={`h-8 px-4 rounded-full text-xs font-medium transition-all ${
328
+ mcpSaving || !mcpFormName.trim() || !mcpFormCommand.trim()
329
+ ? 'bg-agent-muted text-agent-muted-foreground cursor-not-allowed'
330
+ : 'bg-agent-foreground text-agent-canvas hover:opacity-90'
331
+ }`}
332
+ >
333
+ {mcpSaving ? <LuLoaderCircle className="h-3 w-3 animate-spin" /> : '保存'}
334
+ </button>
335
+ </div>
336
+ </div>
337
+ ) : (
338
+ <button
339
+ type="button"
340
+ onClick={() => { resetMcpForm(); setMcpFormOpen(true); }}
341
+ className="flex items-center gap-1.5 text-xs font-medium text-agent-muted-foreground hover:text-agent-foreground transition-colors"
342
+ data-testid="mcp-add-manual"
343
+ >
344
+ <LuPlus className="h-3.5 w-3.5" />
345
+ 手动添加服务
346
+ </button>
347
+ )}
348
+
349
+ {/* 服务列表 */}
350
+ <div className="space-y-2">
351
+ <h4 className="text-xs font-semibold text-agent-muted-foreground uppercase tracking-wide">
352
+ 已注册服务
353
+ </h4>
354
+ {mcpLoading ? (
355
+ <div className="flex items-center gap-2 py-4 text-xs text-agent-muted-foreground">
356
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
357
+ 获取 MCP 服务...
358
+ </div>
359
+ ) : mcpServers.length === 0 ? (
360
+ <div className="text-center py-6 text-xs text-agent-muted-foreground">
361
+ 暂无 MCP 服务,粘贴 JSON 或手动添加
362
+ </div>
363
+ ) : (
364
+ <div className="divide-y divide-agent-border/40 pr-1">
365
+ {mcpServers.map((server) => (
366
+ <div key={server.id} className="py-2.5 flex items-start justify-between gap-3">
367
+ <div className="space-y-1 min-w-0 flex-1">
368
+ <div className="flex items-center gap-1.5 flex-wrap">
369
+ <LuPlug className="h-3.5 w-3.5 text-agent-muted-foreground flex-shrink-0" />
370
+ <span className="text-xs font-medium text-agent-foreground truncate">
371
+ {server.name}
372
+ </span>
373
+ <span
374
+ className={`px-1 py-0.2 rounded text-[9px] font-medium flex-shrink-0 scale-95 origin-left ${
375
+ server.enabled
376
+ ? 'bg-green-500/10 text-green-600'
377
+ : 'bg-agent-muted text-agent-muted-foreground'
378
+ }`}
379
+ >
380
+ {server.enabled ? '已启用' : '已停用'}
381
+ </span>
382
+ {server.enabled && server.toolCount > 0 && (
383
+ <span className="px-1 py-0.2 rounded bg-agent-muted text-[9px] text-agent-muted-foreground font-medium flex-shrink-0 scale-95 origin-left">
384
+ {server.toolCount} 个工具
385
+ </span>
386
+ )}
387
+ </div>
388
+ <p className="text-[11px] text-agent-muted-foreground font-mono truncate">
389
+ {server.command} {(server.args || []).join(' ')}
390
+ </p>
391
+ {server.lastError && (
392
+ <p className="text-[10px] text-agent-destructive line-clamp-2">
393
+ 连接失败:{server.lastError}
394
+ </p>
395
+ )}
396
+ {!server.lastError && server.toolsPreview.length > 0 && (
397
+ <p className="text-[10px] text-agent-muted-foreground/80 line-clamp-1">
398
+ 工具:{server.toolsPreview.join('、')}{server.toolCount > server.toolsPreview.length ? ' …' : ''}
399
+ </p>
400
+ )}
401
+ </div>
402
+ <div className="flex shrink-0 items-center gap-0.5">
403
+ <button
404
+ type="button"
405
+ onClick={() => handleTestMcpServer(server)}
406
+ disabled={mcpTestingId === server.id}
407
+ className="text-agent-muted-foreground hover:text-agent-foreground p-1 rounded-full hover:bg-agent-muted transition-colors"
408
+ title="测试连接并刷新工具列表"
409
+ >
410
+ {mcpTestingId === server.id ? (
411
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
412
+ ) : (
413
+ <LuRefreshCw className="h-3.5 w-3.5" />
414
+ )}
415
+ </button>
416
+ <button
417
+ type="button"
418
+ onClick={() => handleEditMcpServer(server)}
419
+ className="text-agent-muted-foreground hover:text-agent-foreground p-1 rounded-full hover:bg-agent-muted transition-colors"
420
+ title="编辑"
421
+ >
422
+ <LuPencil className="h-3.5 w-3.5" />
423
+ </button>
424
+ <button
425
+ type="button"
426
+ onClick={() => handleToggleMcpServer(server)}
427
+ className="text-agent-muted-foreground hover:text-agent-foreground p-1 rounded-full hover:bg-agent-muted transition-colors"
428
+ title={server.enabled ? '停用' : '启用'}
429
+ >
430
+ {server.enabled ? (
431
+ <LuToggleRight className="h-3.5 w-3.5" />
432
+ ) : (
433
+ <LuToggleLeft className="h-3.5 w-3.5" />
434
+ )}
435
+ </button>
436
+ <button
437
+ type="button"
438
+ onClick={() => handleDeleteMcpServer(server)}
439
+ disabled={mcpDeletingId === server.id}
440
+ className="text-agent-muted-foreground hover:text-agent-destructive p-1 rounded-full hover:bg-agent-muted transition-colors"
441
+ title="删除"
442
+ >
443
+ {mcpDeletingId === server.id ? (
444
+ <LuLoaderCircle className="h-3 w-3 animate-spin" />
445
+ ) : (
446
+ <LuTrash2 className="h-3.5 w-3.5" />
447
+ )}
448
+ </button>
449
+ </div>
450
+ </div>
451
+ ))}
452
+ </div>
453
+ )}
454
+ </div>
455
+ <p className="text-[10px] text-agent-muted-foreground">
456
+ 已启用服务的工具会以 mcp__服务名__工具名 的形式直接提供给助手调用;新导入或修改后点「测试连接」即可生效。
457
+ </p>
458
+
459
+ {error && (
460
+ <div className="rounded-agent-md border border-agent-destructive/20 bg-agent-destructive/10 p-2.5 text-xs text-agent-destructive">
461
+ {error}
462
+ </div>
463
+ )}
464
+ </div>
465
+ );
466
+ }
@@ -0,0 +1,46 @@
1
+ import type { GatewayModelEntry } from '@/lib/local-api';
2
+ import {
3
+ modelCapabilityChips,
4
+ type ModelCapabilityChip,
5
+ } from '@/components/settings/model-capabilities';
6
+
7
+ const CHIP_CLASS: Record<ModelCapabilityChip['kind'], string> = {
8
+ reasoning:
9
+ 'border-agent-border bg-agent-foreground/5 text-agent-foreground',
10
+ modality: 'border-agent-border bg-agent-canvas text-agent-foreground',
11
+ window: 'border-agent-border/80 text-agent-muted-foreground',
12
+ unknown: 'border-agent-border text-agent-muted-foreground',
13
+ };
14
+
15
+ interface ModelCapabilityChipsProps {
16
+ entry: GatewayModelEntry | null | undefined;
17
+ /** 选中摘要:思考筹码带档位。 */
18
+ detail?: boolean;
19
+ className?: string;
20
+ }
21
+
22
+ export function ModelCapabilityChips({
23
+ entry,
24
+ detail = false,
25
+ className,
26
+ }: ModelCapabilityChipsProps) {
27
+ const chips = modelCapabilityChips(entry, { detail });
28
+ if (chips.length === 0) return null;
29
+ return (
30
+ <span className={['inline-flex flex-wrap items-center gap-1', className].filter(Boolean).join(' ')}>
31
+ {chips.map((chip) => (
32
+ <span
33
+ key={chip.key}
34
+ data-capability={chip.key}
35
+ title={chip.title}
36
+ className={[
37
+ 'inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] leading-none',
38
+ CHIP_CLASS[chip.kind],
39
+ ].join(' ')}
40
+ >
41
+ {chip.label}
42
+ </span>
43
+ ))}
44
+ </span>
45
+ );
46
+ }
@@ -0,0 +1,88 @@
1
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { ModelIdCombobox } from './ModelIdCombobox';
4
+ import type { ModelPickerRow } from './llm-vendors';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const OPTIONS = ['deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'];
9
+
10
+ const ROWS: ModelPickerRow[] = [
11
+ {
12
+ id: 'deepseek-v4-pro',
13
+ entry: {
14
+ id: 'deepseek-v4-pro',
15
+ name: 'DeepSeek V4 Pro',
16
+ window: 1_000_000,
17
+ modalities: ['text'],
18
+ reasoningLevels: ['high', 'max'],
19
+ pricing: null,
20
+ joinedFrom: 'deepseek/deepseek-v4-pro',
21
+ capabilities: 'known',
22
+ },
23
+ },
24
+ {
25
+ id: 'deepseek-flash',
26
+ entry: {
27
+ id: 'deepseek-flash',
28
+ name: null,
29
+ window: 131_072,
30
+ modalities: ['text'],
31
+ reasoningLevels: [],
32
+ pricing: null,
33
+ joinedFrom: null,
34
+ capabilities: 'unknown',
35
+ },
36
+ },
37
+ ];
38
+
39
+ describe('ModelIdCombobox', () => {
40
+ it('focus 打开贴齐输入框的列表,点击写入模型 id', () => {
41
+ const onChange = vi.fn();
42
+ render(
43
+ <ModelIdCombobox
44
+ value="deepseek-chat"
45
+ options={OPTIONS}
46
+ placeholder="deepseek-chat"
47
+ onChange={onChange}
48
+ />,
49
+ );
50
+ fireEvent.focus(screen.getByTestId('llm-model-input'));
51
+ const list = screen.getByTestId('llm-model-options');
52
+ expect(list.getAttribute('role')).toBe('listbox');
53
+ fireEvent.click(screen.getByRole('option', { name: 'deepseek-v4-flash' }));
54
+ expect(onChange).toHaveBeenCalledWith('deepseek-v4-flash');
55
+ expect(screen.queryByTestId('llm-model-options')).toBeNull();
56
+ });
57
+
58
+ it('已选中某项时仍列出全部模型', () => {
59
+ render(
60
+ <ModelIdCombobox value="deepseek-v4-flash" options={OPTIONS} onChange={() => {}} />,
61
+ );
62
+ fireEvent.focus(screen.getByTestId('llm-model-input'));
63
+ expect(screen.getByRole('option', { name: 'deepseek-chat' })).toBeTruthy();
64
+ expect(screen.getByRole('option', { name: 'deepseek-v4-pro' })).toBeTruthy();
65
+ });
66
+
67
+ it('手填部分文字时按子串过滤,并允许自定义 id', () => {
68
+ const onChange = vi.fn();
69
+ const { rerender } = render(
70
+ <ModelIdCombobox value="deepseek-chat" options={OPTIONS} onChange={onChange} />,
71
+ );
72
+ fireEvent.change(screen.getByTestId('llm-model-input'), { target: { value: 'flash' } });
73
+ expect(onChange).toHaveBeenCalledWith('flash');
74
+ rerender(<ModelIdCombobox value="flash" options={OPTIONS} onChange={onChange} />);
75
+ expect(screen.getByRole('option', { name: 'deepseek-v4-flash' })).toBeTruthy();
76
+ expect(screen.queryByRole('option', { name: 'deepseek-chat' })).toBeNull();
77
+ });
78
+
79
+ it('shows verified capability chips and keeps option names as the model id', () => {
80
+ render(<ModelIdCombobox value="deepseek-v4-pro" options={ROWS} onChange={() => {}} />);
81
+ fireEvent.focus(screen.getByTestId('llm-model-input'));
82
+ expect(screen.getByRole('option', { name: 'deepseek-v4-pro' })).toBeTruthy();
83
+ expect(screen.getByText('思考')).toBeTruthy();
84
+ expect(screen.getByText('1M')).toBeTruthy();
85
+ expect(screen.getByText('未识别')).toBeTruthy();
86
+ expect(screen.queryByText('思考(high / max)')).toBeNull();
87
+ });
88
+ });