@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,334 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ LuBlocks,
4
+ LuFolderOpen,
5
+ LuLoaderCircle,
6
+ LuTrash2,
7
+ } from 'react-icons/lu';
8
+ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
9
+ import { getPackHiddenSlashSkills } from '@/packs/registry';
10
+
11
+ /**
12
+ * SkillsSettingsPanel — 本地技能管理面板(导入目录 / 列表 / 卸载)。
13
+ *
14
+ * 渲染在 `/settings?section=skills` 独立页(AgentLayout 右侧内容区)。
15
+ *
16
+ * 数据与状态完全自管理:挂载时拉一次列表,操作后刷新。后端走
17
+ * local-backend REST(`GET/POST /api/v2/chat-agents/skills*`),无专用 IPC。
18
+ */
19
+
20
+ const BUILTIN_SKILLS = [
21
+ '00-identity',
22
+ '10-goal',
23
+ '11-loop',
24
+ '12-create-skill',
25
+ '70-plan-mode',
26
+ '80-tool-usage',
27
+ '81-anti-deferred',
28
+ '82-data-grounding',
29
+ '85-local-exec',
30
+ '86-proactive-coding',
31
+ 'identity',
32
+ 'goal',
33
+ 'loop',
34
+ 'create-skill',
35
+ 'plan-mode',
36
+ 'tool-usage',
37
+ 'anti-deferred',
38
+ 'anti-deferred-execution',
39
+ 'data-grounding',
40
+ 'local-exec',
41
+ 'proactive-coding',
42
+ // 场景包的内置技能不在此硬编码——
43
+ // 由包渲染层的 hiddenSlashSkills 声明,经 isBuiltinSkillName 并入。
44
+ ];
45
+
46
+ /** 内置技能判定:shell 内置表 + 包渲染层声明的隐藏技能(包技能随构建拷贝进技能根,属内置)。 */
47
+ function isBuiltinSkillName(name: string): boolean {
48
+ return BUILTIN_SKILLS.includes(name) || getPackHiddenSlashSkills().has(name);
49
+ }
50
+
51
+ export function SkillsSettingsPanel() {
52
+ const [skills, setSkills] = useState<any[]>([]);
53
+ const [skillsLoading, setSkillsLoading] = useState(false);
54
+ const [importPath, setImportPath] = useState('');
55
+ const [importing, setImporting] = useState(false);
56
+ const [importStatus, setImportStatus] = useState<string | null>(null);
57
+ const [deletingName, setDeletingName] = useState<string | null>(null);
58
+ const [error, setError] = useState<string | null>(null);
59
+
60
+ const fetchSkills = useCallback(async () => {
61
+ if (!isElectron()) return;
62
+ setSkillsLoading(true);
63
+ setError(null);
64
+ try {
65
+ const res = await getElectronBridge()!.localBackend.request<{ skills: any[] }>({
66
+ method: 'GET',
67
+ path: '/api/v2/chat-agents/skills',
68
+ });
69
+ setSkills(res.skills || []);
70
+ } catch (err) {
71
+ console.error('获取技能列表失败:', err);
72
+ setError(err instanceof Error ? err.message : String(err));
73
+ } finally {
74
+ setSkillsLoading(false);
75
+ }
76
+ }, []);
77
+
78
+ useEffect(() => {
79
+ void fetchSkills();
80
+ }, [fetchSkills]);
81
+
82
+ const handleBrowseFolder = async () => {
83
+ if (!isElectron()) return;
84
+ try {
85
+ const bridge = getElectronBridge();
86
+ if (bridge?.local?.selectDirectory) {
87
+ setImportStatus('正在打开文件夹选择器...');
88
+ const result = await bridge.local.selectDirectory();
89
+ if (result && !result.canceled && result.filePaths.length > 0) {
90
+ const selectedPath = result.filePaths[0];
91
+ setImportPath(selectedPath);
92
+ setImportStatus(`已选择路径: ${selectedPath}`);
93
+ } else {
94
+ setImportStatus('已取消选择文件夹');
95
+ }
96
+ } else {
97
+ setError('当前版本的客户端不支持文件夹选择器');
98
+ }
99
+ } catch (err) {
100
+ console.error('选择文件夹失败:', err);
101
+ setError(err instanceof Error ? err.message : String(err));
102
+ }
103
+ };
104
+
105
+ const handleImportSkill = async () => {
106
+ const trimmed = importPath.trim();
107
+ if (!trimmed) return;
108
+ setImporting(true);
109
+ setError(null);
110
+ setImportStatus('正在导入本地技能...');
111
+ try {
112
+ const res = await getElectronBridge()!.localBackend.request<{ success: boolean; name: string }>({
113
+ method: 'POST',
114
+ path: '/api/v2/chat-agents/skills/import',
115
+ body: { path: trimmed },
116
+ });
117
+ if (res.success) {
118
+ setImportPath('');
119
+ setImportStatus(`导入成功! 已添加技能 "${res.name}"`);
120
+ await fetchSkills();
121
+ } else {
122
+ setError('导入技能失败');
123
+ setImportStatus('导入失败: 无法将技能拷贝到运行目录');
124
+ }
125
+ } catch (err) {
126
+ const errMsg = err instanceof Error ? err.message : String(err);
127
+ setError(errMsg);
128
+ setImportStatus(`导入出错: ${errMsg}`);
129
+ } finally {
130
+ setImporting(false);
131
+ }
132
+ };
133
+
134
+ const handleDeleteSkill = async (name: string) => {
135
+ if (isBuiltinSkillName(name)) return;
136
+ setDeletingName(name);
137
+ setError(null);
138
+ try {
139
+ const res = await getElectronBridge()!.localBackend.request<{ success: boolean }>({
140
+ method: 'DELETE',
141
+ path: `/api/v2/chat-agents/skills/delete/${encodeURIComponent(name)}`,
142
+ });
143
+ if (res.success) {
144
+ await fetchSkills();
145
+ } else {
146
+ setError('删除技能失败');
147
+ }
148
+ } catch (err) {
149
+ setError(err instanceof Error ? err.message : String(err));
150
+ } finally {
151
+ setDeletingName(null);
152
+ }
153
+ };
154
+
155
+ return (
156
+ <div className="space-y-4">
157
+ {/* Import Skill Bar */}
158
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-2">
159
+ <h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
160
+ <LuBlocks className="h-3.5 w-3.5 text-agent-muted-foreground" />
161
+ 导入本地技能目录 (Import Local Skill Directory)
162
+ </h4>
163
+ <p className="text-[11px] text-agent-muted-foreground">
164
+ 写到当前项目(或工作目录)下 <code>skills/技能名/</code> 的技能会自动出现在下方列表,无需导入。
165
+ 也可以在这里导入其它本地目录(包含 <code>SKILL.md</code> 的文件夹,例如:<code>my-team/sql-tools</code>)。
166
+ </p>
167
+ <div className="flex gap-2">
168
+ <div className="relative flex-1 flex items-center">
169
+ <input
170
+ type="text"
171
+ value={importPath}
172
+ onChange={(e) => setImportPath(e.target.value)}
173
+ placeholder="请输入技能路径,或点击右侧选择文件夹"
174
+ className="w-full pl-3 pr-8 h-8 text-xs bg-agent-canvas text-agent-foreground border border-agent-border rounded-agent-md focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
175
+ disabled={importing}
176
+ data-testid="skills-import-path"
177
+ />
178
+ <button
179
+ type="button"
180
+ onClick={handleBrowseFolder}
181
+ disabled={importing}
182
+ className="absolute right-2.5 text-agent-muted-foreground hover:text-agent-foreground transition-colors"
183
+ title="选择本地文件夹"
184
+ data-testid="skills-import-browse"
185
+ >
186
+ <LuFolderOpen className="h-4 w-4" />
187
+ </button>
188
+ </div>
189
+ <button
190
+ type="button"
191
+ onClick={handleImportSkill}
192
+ disabled={importing || !importPath.trim()}
193
+ data-testid="skills-import-submit"
194
+ className={`h-8 px-4 rounded-full text-xs font-medium transition-all ${
195
+ importing || !importPath.trim()
196
+ ? 'bg-agent-muted text-agent-muted-foreground cursor-not-allowed'
197
+ : 'bg-agent-foreground text-agent-canvas hover:opacity-90'
198
+ }`}
199
+ >
200
+ {importing ? (
201
+ <LuLoaderCircle className="h-3 w-3 animate-spin" />
202
+ ) : (
203
+ '导入'
204
+ )}
205
+ </button>
206
+ </div>
207
+ {importStatus && (
208
+ <p
209
+ className="text-[10px] text-agent-muted-foreground bg-agent-muted/10 px-2 py-1 rounded border border-agent-border/20 mt-1"
210
+ data-testid="skills-import-status"
211
+ >
212
+ {importStatus}
213
+ </p>
214
+ )}
215
+ </div>
216
+
217
+ {/* Skills List */}
218
+ <div className="space-y-2">
219
+ <h4 className="text-xs font-semibold text-agent-muted-foreground uppercase tracking-wide">
220
+ 已加载技能
221
+ </h4>
222
+ {skillsLoading ? (
223
+ <div className="flex items-center gap-2 py-4 text-xs text-agent-muted-foreground">
224
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
225
+ 获取已加载技能...
226
+ </div>
227
+ ) : skills.length === 0 ? (
228
+ <div className="text-center py-6 text-xs text-agent-muted-foreground">
229
+ 暂无已加载技能
230
+ </div>
231
+ ) : (
232
+ <div className="divide-y divide-agent-border/40 pr-1">
233
+ {skills.map((skill) => {
234
+ const origin = skill.origin as string | undefined;
235
+ const isBuiltin =
236
+ origin === 'builtin' ||
237
+ (origin !== 'user' && origin !== 'workspace' && (skill.isBuiltin ?? isBuiltinSkillName(skill.name)));
238
+ const isWorkspace = origin === 'workspace';
239
+ const canUninstall = !isBuiltin && !isWorkspace && !isBuiltinSkillName(skill.name);
240
+ return (
241
+ <div
242
+ key={skill.name}
243
+ className="py-2.5 flex items-start justify-between gap-3 group"
244
+ data-testid={`skill-row-${skill.name}`}
245
+ data-skill-name={skill.name}
246
+ >
247
+ <div className="space-y-1 min-w-0 flex-1">
248
+ <div className="flex items-center gap-1.5 flex-wrap">
249
+ <LuBlocks className="h-3.5 w-3.5 text-agent-muted-foreground flex-shrink-0" />
250
+ <span className="text-xs font-medium text-agent-foreground truncate">
251
+ {skill.displayName || skill.name}
252
+ </span>
253
+ {skill.displayName && (
254
+ <span className="text-[10px] text-agent-muted-foreground/70 truncate">
255
+ {skill.name}
256
+ </span>
257
+ )}
258
+ {isBuiltin && (
259
+ <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">
260
+ 内置
261
+ </span>
262
+ )}
263
+ {isWorkspace && (
264
+ <span
265
+ 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"
266
+ title="来自项目或工作目录的 skills/ 文件夹,写进去即出现在此列表"
267
+ >
268
+ 工作区
269
+ </span>
270
+ )}
271
+ {skill.layer === 'eager' ? (
272
+ <span
273
+ 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"
274
+ title="常驻层:正文始终注入系统提示词"
275
+ >
276
+ 常驻
277
+ </span>
278
+ ) : (
279
+ <span
280
+ 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"
281
+ title="按需层:仅在目录中列出,模型经 skill 工具按需加载"
282
+ >
283
+ 按需
284
+ </span>
285
+ )}
286
+ {skill.modelInvocable === false && (
287
+ <span
288
+ 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"
289
+ title="disable-model-invocation:模型不可调用,只能用 /名称 手动触发"
290
+ >
291
+ 仅手动
292
+ </span>
293
+ )}
294
+ </div>
295
+ {skill.description && (
296
+ <p className="text-[11px] text-agent-muted-foreground leading-relaxed line-clamp-2">
297
+ {skill.description}
298
+ </p>
299
+ )}
300
+ </div>
301
+ {canUninstall && (
302
+ <button
303
+ type="button"
304
+ onClick={() => handleDeleteSkill(skill.name)}
305
+ disabled={deletingName === skill.name}
306
+ className="text-agent-muted-foreground hover:text-agent-destructive p-1 rounded-full hover:bg-agent-muted transition-colors"
307
+ title="卸载此技能"
308
+ data-testid={`skill-uninstall-${skill.name}`}
309
+ >
310
+ {deletingName === skill.name ? (
311
+ <LuLoaderCircle className="h-3 w-3 animate-spin" />
312
+ ) : (
313
+ <LuTrash2 className="h-3.5 w-3.5" />
314
+ )}
315
+ </button>
316
+ )}
317
+ </div>
318
+ );
319
+ })}
320
+ </div>
321
+ )}
322
+ </div>
323
+
324
+ {error && (
325
+ <div
326
+ className="rounded-agent-md border border-agent-destructive/20 bg-agent-destructive/10 p-2.5 text-xs text-agent-destructive"
327
+ data-testid="skills-error"
328
+ >
329
+ {error}
330
+ </div>
331
+ )}
332
+ </div>
333
+ );
334
+ }
@@ -0,0 +1,173 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { LuLoaderCircle, LuActivity } from 'react-icons/lu';
3
+ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
4
+
5
+ /**
6
+ * TelemetrySettingsPanel — W6-6 遥测合规化的桌面 collector 配置面板。
7
+ *
8
+ * 配置 OTLP/HTTP collector 地址与隐私档位,后端落 `settings_kv`
9
+ * (key = telemetry_settings),每轮 CoreLoop 结束后按所选档位把该轮 trace
10
+ * 导出到 collector(默认 metadata:只出结构/时延/状态,不出内容)。
11
+ *
12
+ * 隐私模型(与 framework otel.py 的 PrivacyMode 对齐):
13
+ * - 留空地址 = 关。一条 trace 都不出进程(默认)。
14
+ * - metadata = 只导出 span/事件的结构、时延、状态;payload 正文与自由属性丢弃。
15
+ * - full = 导出(已脱敏的)payload 与属性,仅当 collector 可信时选。
16
+ *
17
+ * 数据自管理:挂载拉一次,保存后刷新。走 local-backend REST
18
+ * (`/api/v2/local-settings/telemetry`),无专用 IPC channel。
19
+ */
20
+
21
+ type PrivacyMode = 'metadata' | 'full';
22
+
23
+ interface TelemetrySettingsWire {
24
+ endpoint?: string;
25
+ privacyMode?: PrivacyMode;
26
+ serviceName?: string;
27
+ }
28
+
29
+ export function TelemetrySettingsPanel() {
30
+ const [endpoint, setEndpoint] = useState('');
31
+ const [privacyMode, setPrivacyMode] = useState<PrivacyMode>('metadata');
32
+ const [loading, setLoading] = useState(false);
33
+ const [saving, setSaving] = useState(false);
34
+ const [status, setStatus] = useState<string | null>(null);
35
+ const [error, setError] = useState<string | null>(null);
36
+
37
+ const fetchSettings = useCallback(async () => {
38
+ if (!isElectron()) return;
39
+ setLoading(true);
40
+ setError(null);
41
+ try {
42
+ const res = await getElectronBridge()!.localBackend.request<TelemetrySettingsWire | null>({
43
+ method: 'GET',
44
+ path: '/api/v2/local-settings/telemetry',
45
+ });
46
+ setEndpoint(res?.endpoint ?? '');
47
+ setPrivacyMode(res?.privacyMode === 'full' ? 'full' : 'metadata');
48
+ } catch (err) {
49
+ setError(err instanceof Error ? err.message : String(err));
50
+ } finally {
51
+ setLoading(false);
52
+ }
53
+ }, []);
54
+
55
+ useEffect(() => {
56
+ void fetchSettings();
57
+ }, [fetchSettings]);
58
+
59
+ const handleSave = async () => {
60
+ if (!isElectron()) return;
61
+ setSaving(true);
62
+ setError(null);
63
+ setStatus(null);
64
+ try {
65
+ const saved = await getElectronBridge()!.localBackend.request<TelemetrySettingsWire>({
66
+ method: 'POST',
67
+ path: '/api/v2/local-settings/telemetry',
68
+ body: { endpoint: endpoint.trim(), privacyMode },
69
+ });
70
+ setEndpoint(saved.endpoint ?? '');
71
+ setStatus(
72
+ saved.endpoint
73
+ ? `已保存 — 遥测开启(${saved.privacyMode === 'full' ? '完整(已脱敏)' : '仅元数据'})`
74
+ : '已保存 — 未配置 collector 地址,遥测保持关闭',
75
+ );
76
+ } catch (err) {
77
+ setError(err instanceof Error ? err.message : String(err));
78
+ } finally {
79
+ setSaving(false);
80
+ }
81
+ };
82
+
83
+ return (
84
+ <div className="space-y-4">
85
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-3">
86
+ <h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
87
+ <LuActivity className="h-3.5 w-3.5 text-agent-muted-foreground" />
88
+ OTLP Collector
89
+ </h4>
90
+
91
+ {loading ? (
92
+ <div className="flex items-center gap-2 py-2 text-xs text-agent-muted-foreground">
93
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
94
+ 读取遥测设置...
95
+ </div>
96
+ ) : (
97
+ <>
98
+ <div className="space-y-1.5">
99
+ <label className="text-[11px] font-medium text-agent-muted-foreground">
100
+ Collector 地址(/v1/traces)
101
+ </label>
102
+ <input
103
+ type="text"
104
+ value={endpoint}
105
+ onChange={(e) => setEndpoint(e.target.value)}
106
+ placeholder="http://127.0.0.1:4318/v1/traces(留空 = 关闭遥测)"
107
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 font-mono text-[11px] text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
108
+ />
109
+ </div>
110
+
111
+ <div className="space-y-1.5">
112
+ <label className="text-[11px] font-medium text-agent-muted-foreground">
113
+ 隐私档位
114
+ </label>
115
+ <div className="flex gap-2">
116
+ {(
117
+ [
118
+ { value: 'metadata', label: '仅元数据', hint: '结构/时延/状态,不含内容' },
119
+ { value: 'full', label: '完整(已脱敏)', hint: '含 payload,密钥已打码' },
120
+ ] as const
121
+ ).map((opt) => (
122
+ <button
123
+ key={opt.value}
124
+ type="button"
125
+ onClick={() => setPrivacyMode(opt.value)}
126
+ className={`flex-1 rounded-agent-md border px-3 py-2 text-left transition-colors ${
127
+ privacyMode === opt.value
128
+ ? 'border-agent-foreground/40 bg-agent-foreground/5'
129
+ : 'border-agent-border bg-agent-canvas hover:bg-agent-muted/40'
130
+ }`}
131
+ >
132
+ <div className="text-xs font-medium text-agent-foreground">{opt.label}</div>
133
+ <div className="text-[10px] text-agent-muted-foreground">{opt.hint}</div>
134
+ </button>
135
+ ))}
136
+ </div>
137
+ </div>
138
+
139
+ <div className="flex items-center justify-between gap-2">
140
+ <p className="text-[10px] text-agent-muted-foreground">
141
+ 无论哪一档,密钥/令牌在导出前都会被脱敏;留空地址则完全不导出。
142
+ </p>
143
+ <button
144
+ type="button"
145
+ onClick={handleSave}
146
+ disabled={saving}
147
+ className={`h-8 shrink-0 px-4 rounded-full text-xs font-medium transition-all ${
148
+ saving
149
+ ? 'bg-agent-muted text-agent-muted-foreground cursor-not-allowed'
150
+ : 'bg-agent-foreground text-agent-canvas hover:opacity-90'
151
+ }`}
152
+ >
153
+ {saving ? <LuLoaderCircle className="h-3 w-3 animate-spin" /> : '保存'}
154
+ </button>
155
+ </div>
156
+
157
+ {status && (
158
+ <p className="text-[10px] text-agent-muted-foreground bg-agent-muted/10 px-2 py-1 rounded border border-agent-border/20">
159
+ {status}
160
+ </p>
161
+ )}
162
+ </>
163
+ )}
164
+ </div>
165
+
166
+ {error && (
167
+ <div className="rounded-agent-md border border-agent-destructive/20 bg-agent-destructive/10 p-2.5 text-xs text-agent-destructive">
168
+ {error}
169
+ </div>
170
+ )}
171
+ </div>
172
+ );
173
+ }