@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.
- package/LICENSE +91 -0
- package/README.md +20 -0
- package/package.json +69 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/src/AppShell.tsx +24 -0
- package/src/assets/logo-generic.png +0 -0
- package/src/brand.test.ts +22 -0
- package/src/brand.ts +48 -0
- package/src/components/AgentSidebar.test.tsx +127 -0
- package/src/components/AgentSidebar.tsx +1010 -0
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/ChatHeader.tsx +436 -0
- package/src/components/DockPanelHeader.tsx +69 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/LocalLlmSettingsModal.tsx +72 -0
- package/src/components/TerminalPanel.tsx +68 -0
- package/src/components/TerminalView.tsx +228 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/ApprovalModal.tsx +229 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserModalHost.tsx +61 -0
- package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/AssistantMessage.tsx +469 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ChatInput.tsx +2068 -0
- package/src/components/chat/ChatProjectBadge.tsx +362 -0
- package/src/components/chat/EmptyChat.tsx +18 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/ExecPolicyPicker.tsx +120 -0
- package/src/components/chat/ExecutedActionsCard.tsx +216 -0
- package/src/components/chat/InterruptedTurnCard.tsx +53 -0
- package/src/components/chat/LocalChatPanel.tsx +408 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/Markdown.tsx +432 -0
- package/src/components/chat/MessageList.tsx +332 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/ModelPicker.tsx +289 -0
- package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
- package/src/components/chat/PlanTodoList.tsx +121 -0
- package/src/components/chat/ProjectTrustBanner.tsx +91 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/SessionTreeModal.tsx +221 -0
- package/src/components/chat/StreamingStatus.tsx +95 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskOutcomeCards.tsx +128 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskPanelModal.tsx +310 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TaskProcessPanel.tsx +208 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/TurnProcessGroup.tsx +193 -0
- package/src/components/chat/UnifiedDiff.tsx +31 -0
- package/src/components/chat/UserMessage.tsx +155 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/elapsed.ts +45 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/executed-actions-model.ts +143 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/orchestration-children-model.ts +104 -0
- package/src/components/chat/timestamp.ts +24 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/turn-timeline.ts +132 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/chat/useChatTasks.ts +134 -0
- package/src/components/chat/useCopy.ts +62 -0
- package/src/components/settings/AgentCapabilityFields.tsx +343 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
- package/src/components/settings/LlmSettingsPanel.tsx +875 -0
- package/src/components/settings/McpSettingsPanel.tsx +466 -0
- package/src/components/settings/ModelCapabilityChips.tsx +46 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/ModelIdCombobox.tsx +185 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
- package/src/components/settings/SettingsSaveButton.tsx +35 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
- package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
- package/src/components/settings/UsagePanel.tsx +196 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/compat-flags-model.ts +57 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/llm-vendors.ts +275 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/model-capabilities.ts +123 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/components/settings/preset-choice-model.ts +75 -0
- package/src/fixtures/browser-dev-data.json +315 -0
- package/src/hooks/useChatsAndAgents.ts +182 -0
- package/src/layouts/AgentLayout.tsx +574 -0
- package/src/lib/attachments.ts +78 -0
- package/src/lib/browser-dev-electron-mock.ts +640 -0
- package/src/lib/chat-title.ts +33 -0
- package/src/lib/chat-transport.ts +346 -0
- package/src/lib/date-groups.ts +34 -0
- package/src/lib/demo-flag.ts +15 -0
- package/src/lib/electron-bridge.ts +270 -0
- package/src/lib/exec-policy.ts +25 -0
- package/src/lib/http-bridge.ts +293 -0
- package/src/lib/insights.ts +17 -0
- package/src/lib/local-api.ts +714 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/pending-first-message.ts +36 -0
- package/src/lib/slash-sources.ts +191 -0
- package/src/main.tsx +103 -0
- package/src/packs/registry.ts +64 -0
- package/src/pages/AgentPage.tsx +1226 -0
- package/src/pages/ChatPanelPreviewPage.tsx +264 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
- package/src/pages/SettingsPage.tsx +197 -0
- package/src/styles/index.css +156 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.base.ts +94 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { LuLoaderCircle, LuChartBar, LuRefreshCw } from 'react-icons/lu';
|
|
3
|
+
import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* UsagePanel — W6-9 用量与成本归因的桌面面板。
|
|
7
|
+
*
|
|
8
|
+
* 每轮 CoreLoop 结束后,local-backend 把该轮的累计 token 用量(以及按
|
|
9
|
+
* framework pricing 单价表估算的 USD 成本)落进 `usage_events` 表;本面板
|
|
10
|
+
* 按 model 分桶聚合展示近 N 天的用量与成本。
|
|
11
|
+
*
|
|
12
|
+
* 成本口径:只统计有单价的模型(framework `MODEL_PRICES`);本地/未知模型
|
|
13
|
+
* 无单价,token 照常计入但成本列渲染为 "—",不计入总成本。
|
|
14
|
+
*
|
|
15
|
+
* 数据自管理:挂载拉一次,可手动刷新 / 切换时间窗。走 local-backend REST
|
|
16
|
+
* (`/api/v2/usage/summary?days=N`),无专用 IPC channel。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface UsageModelBucketWire {
|
|
20
|
+
model: string;
|
|
21
|
+
provider: string;
|
|
22
|
+
turns: number;
|
|
23
|
+
promptTokens: number;
|
|
24
|
+
completionTokens: number;
|
|
25
|
+
totalTokens: number;
|
|
26
|
+
cachedPromptTokens: number;
|
|
27
|
+
costUsd: number | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface UsageSummaryWire {
|
|
31
|
+
sinceDays: number;
|
|
32
|
+
byModel: UsageModelBucketWire[];
|
|
33
|
+
totals: {
|
|
34
|
+
turns: number;
|
|
35
|
+
promptTokens: number;
|
|
36
|
+
completionTokens: number;
|
|
37
|
+
totalTokens: number;
|
|
38
|
+
cachedPromptTokens: number;
|
|
39
|
+
costUsd: number;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const WINDOW_OPTIONS = [
|
|
44
|
+
{ days: 7, label: '近 7 天' },
|
|
45
|
+
{ days: 30, label: '近 30 天' },
|
|
46
|
+
{ days: 90, label: '近 90 天' },
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
function formatTokens(n: number): string {
|
|
50
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
51
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
52
|
+
return String(n);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatCost(usd: number | null): string {
|
|
56
|
+
if (usd === null) return '—';
|
|
57
|
+
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
|
58
|
+
return `$${usd.toFixed(2)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function UsagePanel() {
|
|
62
|
+
const [days, setDays] = useState<number>(30);
|
|
63
|
+
const [summary, setSummary] = useState<UsageSummaryWire | null>(null);
|
|
64
|
+
const [loading, setLoading] = useState(false);
|
|
65
|
+
const [error, setError] = useState<string | null>(null);
|
|
66
|
+
|
|
67
|
+
const fetchSummary = useCallback(async (windowDays: number) => {
|
|
68
|
+
if (!isElectron()) return;
|
|
69
|
+
setLoading(true);
|
|
70
|
+
setError(null);
|
|
71
|
+
try {
|
|
72
|
+
const res = await getElectronBridge()!.localBackend.request<UsageSummaryWire>({
|
|
73
|
+
method: 'GET',
|
|
74
|
+
path: `/api/v2/usage/summary?days=${windowDays}`,
|
|
75
|
+
});
|
|
76
|
+
setSummary(res);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
79
|
+
} finally {
|
|
80
|
+
setLoading(false);
|
|
81
|
+
}
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
void fetchSummary(days);
|
|
86
|
+
}, [fetchSummary, days]);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="space-y-4">
|
|
90
|
+
<div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-3">
|
|
91
|
+
<div className="flex items-center justify-between">
|
|
92
|
+
<h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
|
|
93
|
+
<LuChartBar className="h-3.5 w-3.5 text-agent-muted-foreground" />
|
|
94
|
+
用量与成本
|
|
95
|
+
</h4>
|
|
96
|
+
<div className="flex items-center gap-1.5">
|
|
97
|
+
{WINDOW_OPTIONS.map((opt) => (
|
|
98
|
+
<button
|
|
99
|
+
key={opt.days}
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={() => setDays(opt.days)}
|
|
102
|
+
className={`h-6 px-2 rounded-full text-[10px] font-medium transition-colors ${
|
|
103
|
+
days === opt.days
|
|
104
|
+
? 'bg-agent-foreground text-agent-canvas'
|
|
105
|
+
: 'bg-agent-canvas text-agent-muted-foreground border border-agent-border hover:bg-agent-muted/40'
|
|
106
|
+
}`}
|
|
107
|
+
>
|
|
108
|
+
{opt.label}
|
|
109
|
+
</button>
|
|
110
|
+
))}
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={() => void fetchSummary(days)}
|
|
114
|
+
disabled={loading}
|
|
115
|
+
className="h-6 w-6 rounded-full border border-agent-border bg-agent-canvas text-agent-muted-foreground hover:bg-agent-muted/40 flex items-center justify-center"
|
|
116
|
+
title="刷新"
|
|
117
|
+
>
|
|
118
|
+
<LuRefreshCw className={`h-3 w-3 ${loading ? 'animate-spin' : ''}`} />
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{loading && !summary ? (
|
|
124
|
+
<div className="flex items-center gap-2 py-4 text-xs text-agent-muted-foreground">
|
|
125
|
+
<LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
|
126
|
+
读取用量...
|
|
127
|
+
</div>
|
|
128
|
+
) : !summary || summary.byModel.length === 0 ? (
|
|
129
|
+
<p className="text-[11px] text-agent-muted-foreground py-2">
|
|
130
|
+
该时间窗内暂无用量记录。完成一轮对话后,这里会按模型聚合 token 与成本。
|
|
131
|
+
</p>
|
|
132
|
+
) : (
|
|
133
|
+
<>
|
|
134
|
+
{/* 总计卡片 */}
|
|
135
|
+
<div className="grid grid-cols-4 gap-2">
|
|
136
|
+
<div className="rounded-agent-md border border-agent-border bg-agent-canvas px-2.5 py-2">
|
|
137
|
+
<div className="text-[10px] text-agent-muted-foreground">总轮次</div>
|
|
138
|
+
<div className="text-sm font-semibold text-agent-foreground">{summary.totals.turns}</div>
|
|
139
|
+
</div>
|
|
140
|
+
<div className="rounded-agent-md border border-agent-border bg-agent-canvas px-2.5 py-2">
|
|
141
|
+
<div className="text-[10px] text-agent-muted-foreground">总 Token</div>
|
|
142
|
+
<div className="text-sm font-semibold text-agent-foreground">{formatTokens(summary.totals.totalTokens)}</div>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="rounded-agent-md border border-agent-border bg-agent-canvas px-2.5 py-2">
|
|
145
|
+
<div className="text-[10px] text-agent-muted-foreground">缓存命中</div>
|
|
146
|
+
<div className="text-sm font-semibold text-agent-foreground">{formatTokens(summary.totals.cachedPromptTokens)}</div>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="rounded-agent-md border border-agent-border bg-agent-canvas px-2.5 py-2">
|
|
149
|
+
<div className="text-[10px] text-agent-muted-foreground">估算成本</div>
|
|
150
|
+
<div className="text-sm font-semibold text-agent-foreground">{formatCost(summary.totals.costUsd)}</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* 按模型分桶 */}
|
|
155
|
+
<div className="rounded-agent-md border border-agent-border overflow-hidden">
|
|
156
|
+
<table className="w-full text-[11px]">
|
|
157
|
+
<thead>
|
|
158
|
+
<tr className="bg-agent-muted/40 text-agent-muted-foreground">
|
|
159
|
+
<th className="text-left font-medium px-2.5 py-1.5">模型</th>
|
|
160
|
+
<th className="text-right font-medium px-2.5 py-1.5">轮次</th>
|
|
161
|
+
<th className="text-right font-medium px-2.5 py-1.5">输入</th>
|
|
162
|
+
<th className="text-right font-medium px-2.5 py-1.5">输出</th>
|
|
163
|
+
<th className="text-right font-medium px-2.5 py-1.5">成本</th>
|
|
164
|
+
</tr>
|
|
165
|
+
</thead>
|
|
166
|
+
<tbody>
|
|
167
|
+
{summary.byModel.map((b) => (
|
|
168
|
+
<tr key={`${b.provider}/${b.model}`} className="border-t border-agent-border/50">
|
|
169
|
+
<td className="px-2.5 py-1.5 font-mono text-agent-foreground truncate max-w-[140px]" title={b.model}>
|
|
170
|
+
{b.model}
|
|
171
|
+
</td>
|
|
172
|
+
<td className="px-2.5 py-1.5 text-right text-agent-muted-foreground">{b.turns}</td>
|
|
173
|
+
<td className="px-2.5 py-1.5 text-right text-agent-muted-foreground">{formatTokens(b.promptTokens)}</td>
|
|
174
|
+
<td className="px-2.5 py-1.5 text-right text-agent-muted-foreground">{formatTokens(b.completionTokens)}</td>
|
|
175
|
+
<td className="px-2.5 py-1.5 text-right text-agent-foreground">{formatCost(b.costUsd)}</td>
|
|
176
|
+
</tr>
|
|
177
|
+
))}
|
|
178
|
+
</tbody>
|
|
179
|
+
</table>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<p className="text-[10px] text-agent-muted-foreground">
|
|
183
|
+
成本按 framework 单价表估算,只统计有单价的模型;"—" 表示该模型无单价(本地/未知)。缓存命中为命中 prompt 缓存的 token 数。
|
|
184
|
+
</p>
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{error && (
|
|
190
|
+
<div className="rounded-agent-md border border-agent-destructive/20 bg-agent-destructive/10 p-2.5 text-xs text-agent-destructive">
|
|
191
|
+
{error}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const request = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock('@/lib/electron-bridge', () => ({
|
|
7
|
+
isElectron: () => true,
|
|
8
|
+
getElectronBridge: () => ({
|
|
9
|
+
localBackend: { request },
|
|
10
|
+
}),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const { WebSearchSettingsPanel } = await import('./WebSearchSettingsPanel');
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
request.mockReset();
|
|
17
|
+
request.mockResolvedValue({ provider: 'tavily', apiKey: '' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(cleanup);
|
|
21
|
+
|
|
22
|
+
describe('WebSearchSettingsPanel', () => {
|
|
23
|
+
it('loads Tavily by default and hides the key field on the free backend', async () => {
|
|
24
|
+
render(<WebSearchSettingsPanel />);
|
|
25
|
+
await screen.findByTestId('web-search-provider-ddg');
|
|
26
|
+
expect(screen.getByText('Tavily API Key')).toBeTruthy();
|
|
27
|
+
|
|
28
|
+
fireEvent.click(screen.getByTestId('web-search-provider-ddg'));
|
|
29
|
+
expect(screen.queryByText('Tavily API Key')).toBeNull();
|
|
30
|
+
expect(screen.getByText(/DuckDuckGo 公开搜索页/)).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('POSTs provider=ddg and shows the restart copy', async () => {
|
|
34
|
+
request
|
|
35
|
+
.mockResolvedValueOnce({ provider: 'tavily' })
|
|
36
|
+
.mockResolvedValueOnce({ provider: 'ddg' });
|
|
37
|
+
|
|
38
|
+
render(<WebSearchSettingsPanel />);
|
|
39
|
+
await screen.findByTestId('web-search-provider-ddg');
|
|
40
|
+
fireEvent.click(screen.getByTestId('web-search-provider-ddg'));
|
|
41
|
+
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
|
42
|
+
|
|
43
|
+
await waitFor(() => {
|
|
44
|
+
expect(request).toHaveBeenCalledWith(
|
|
45
|
+
expect.objectContaining({
|
|
46
|
+
method: 'POST',
|
|
47
|
+
path: '/api/v2/local-settings/web-search',
|
|
48
|
+
body: { provider: 'ddg', apiKey: '' },
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
expect(await screen.findByText(/重启应用后 sidecar 会注册免费搜索/)).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps 免费 selected when the save echo omits provider', async () => {
|
|
56
|
+
request.mockResolvedValueOnce({ provider: 'tavily' }).mockResolvedValueOnce({});
|
|
57
|
+
|
|
58
|
+
render(<WebSearchSettingsPanel />);
|
|
59
|
+
await screen.findByTestId('web-search-provider-ddg');
|
|
60
|
+
fireEvent.click(screen.getByTestId('web-search-provider-ddg'));
|
|
61
|
+
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
|
62
|
+
|
|
63
|
+
expect(await screen.findByText(/重启应用后 sidecar 会注册免费搜索/)).toBeTruthy();
|
|
64
|
+
expect(screen.queryByText('Tavily API Key')).toBeNull();
|
|
65
|
+
expect(screen.getByTestId('web-search-provider-ddg').className).toContain('border-agent-foreground/40');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('POSTs a Tavily key and keeps the key field', async () => {
|
|
69
|
+
request
|
|
70
|
+
.mockResolvedValueOnce({ provider: 'tavily' })
|
|
71
|
+
.mockResolvedValueOnce({ provider: 'tavily', apiKey: 'tvly-k' });
|
|
72
|
+
|
|
73
|
+
render(<WebSearchSettingsPanel />);
|
|
74
|
+
const keyInput = await screen.findByPlaceholderText('tvly-...(留空 = 不注册 Tavily)');
|
|
75
|
+
fireEvent.change(keyInput, { target: { value: ' tvly-k ' } });
|
|
76
|
+
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
|
77
|
+
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(request).toHaveBeenCalledWith(
|
|
80
|
+
expect.objectContaining({
|
|
81
|
+
method: 'POST',
|
|
82
|
+
path: '/api/v2/local-settings/web-search',
|
|
83
|
+
body: { provider: 'tavily', apiKey: 'tvly-k' },
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
expect(await screen.findByText(/重启应用后 sidecar 会注册 web_search/)).toBeTruthy();
|
|
88
|
+
expect(screen.getByText('Tavily API Key')).toBeTruthy();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { LuLoaderCircle, LuSearch } from 'react-icons/lu';
|
|
3
|
+
import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Search backend settings. `ddg` registers `web_search` with no key.
|
|
7
|
+
* Tavily still needs a key. Empty Tavily + OpenAI hosted search is handled
|
|
8
|
+
* on spawn, not here. DuckDuckGo is never a silent fallback.
|
|
9
|
+
*
|
|
10
|
+
* Saved to settings_kv; injected as STEERABLE_WEB_SEARCH_PROVIDER / API_KEY
|
|
11
|
+
* on the next sidecar spawn (restart the app after saving).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type SearchProvider = 'ddg' | 'tavily';
|
|
15
|
+
|
|
16
|
+
interface WebSearchSettingsWire {
|
|
17
|
+
provider?: SearchProvider;
|
|
18
|
+
apiKey?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readProvider(raw: unknown, fallback: SearchProvider): SearchProvider {
|
|
22
|
+
if (raw === 'ddg' || raw === 'tavily') return raw;
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function WebSearchSettingsPanel() {
|
|
27
|
+
const [provider, setProvider] = useState<SearchProvider>('tavily');
|
|
28
|
+
const [apiKey, setApiKey] = useState('');
|
|
29
|
+
const [loading, setLoading] = useState(false);
|
|
30
|
+
const [saving, setSaving] = useState(false);
|
|
31
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
const fetchSettings = useCallback(async () => {
|
|
35
|
+
if (!isElectron()) return;
|
|
36
|
+
setLoading(true);
|
|
37
|
+
setError(null);
|
|
38
|
+
try {
|
|
39
|
+
const res = await getElectronBridge()!.localBackend.request<WebSearchSettingsWire | null>({
|
|
40
|
+
method: 'GET',
|
|
41
|
+
path: '/api/v2/local-settings/web-search',
|
|
42
|
+
});
|
|
43
|
+
setProvider(readProvider(res?.provider, 'tavily'));
|
|
44
|
+
setApiKey(res?.apiKey ?? '');
|
|
45
|
+
} catch (err) {
|
|
46
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
47
|
+
} finally {
|
|
48
|
+
setLoading(false);
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
void fetchSettings();
|
|
54
|
+
}, [fetchSettings]);
|
|
55
|
+
|
|
56
|
+
const handleSave = async () => {
|
|
57
|
+
if (!isElectron()) return;
|
|
58
|
+
setSaving(true);
|
|
59
|
+
setError(null);
|
|
60
|
+
setStatus(null);
|
|
61
|
+
try {
|
|
62
|
+
const posted = provider;
|
|
63
|
+
const saved = await getElectronBridge()!.localBackend.request<WebSearchSettingsWire>({
|
|
64
|
+
method: 'POST',
|
|
65
|
+
path: '/api/v2/local-settings/web-search',
|
|
66
|
+
body: { provider: posted, apiKey: apiKey.trim() },
|
|
67
|
+
});
|
|
68
|
+
// Old main processes echo `{ apiKey }` only. Missing provider must not
|
|
69
|
+
// snap the radio back to Tavily — keep what the user just saved.
|
|
70
|
+
const nextProvider = readProvider(saved?.provider, posted);
|
|
71
|
+
setProvider(nextProvider);
|
|
72
|
+
setApiKey(saved?.apiKey ?? '');
|
|
73
|
+
if (nextProvider === 'ddg') {
|
|
74
|
+
setStatus('已保存 — 重启应用后 sidecar 会注册免费搜索(DuckDuckGo)');
|
|
75
|
+
} else if (saved?.apiKey) {
|
|
76
|
+
setStatus('已保存 — 重启应用后 sidecar 会注册 web_search');
|
|
77
|
+
} else {
|
|
78
|
+
setStatus('已保存 — 未配置则模型没有搜索工具(OpenAI 可用聊天凭证走托管搜索)');
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
82
|
+
} finally {
|
|
83
|
+
setSaving(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="space-y-4">
|
|
89
|
+
<div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-3">
|
|
90
|
+
<h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
|
|
91
|
+
<LuSearch className="h-3.5 w-3.5 text-agent-muted-foreground" />
|
|
92
|
+
网络搜索
|
|
93
|
+
</h4>
|
|
94
|
+
|
|
95
|
+
{loading ? (
|
|
96
|
+
<div className="flex items-center gap-2 py-2 text-xs text-agent-muted-foreground">
|
|
97
|
+
<LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
|
98
|
+
读取搜索设置...
|
|
99
|
+
</div>
|
|
100
|
+
) : (
|
|
101
|
+
<>
|
|
102
|
+
<div className="space-y-1.5">
|
|
103
|
+
<label className="text-[11px] font-medium text-agent-muted-foreground">
|
|
104
|
+
搜索后端
|
|
105
|
+
</label>
|
|
106
|
+
<div className="flex gap-2">
|
|
107
|
+
{(
|
|
108
|
+
[
|
|
109
|
+
{ value: 'ddg', label: '免费', hint: 'DuckDuckGo,无需 Key' },
|
|
110
|
+
{ value: 'tavily', label: 'Tavily', hint: '需 API Key,结果更稳' },
|
|
111
|
+
] as const
|
|
112
|
+
).map((opt) => (
|
|
113
|
+
<button
|
|
114
|
+
key={opt.value}
|
|
115
|
+
type="button"
|
|
116
|
+
data-testid={`web-search-provider-${opt.value}`}
|
|
117
|
+
onClick={() => setProvider(opt.value)}
|
|
118
|
+
className={`flex-1 rounded-agent-md border px-3 py-2 text-left transition-colors ${
|
|
119
|
+
provider === opt.value
|
|
120
|
+
? 'border-agent-foreground/40 bg-agent-foreground/5'
|
|
121
|
+
: 'border-agent-border bg-agent-canvas hover:bg-agent-muted/40'
|
|
122
|
+
}`}
|
|
123
|
+
>
|
|
124
|
+
<div className="text-xs font-medium text-agent-foreground">{opt.label}</div>
|
|
125
|
+
<div className="text-[10px] text-agent-muted-foreground">{opt.hint}</div>
|
|
126
|
+
</button>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{provider === 'tavily' && (
|
|
132
|
+
<div className="space-y-1.5">
|
|
133
|
+
<label className="text-[11px] font-medium text-agent-muted-foreground">
|
|
134
|
+
Tavily API Key
|
|
135
|
+
</label>
|
|
136
|
+
<input
|
|
137
|
+
type="password"
|
|
138
|
+
value={apiKey}
|
|
139
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
140
|
+
placeholder="tvly-...(留空 = 不注册 Tavily)"
|
|
141
|
+
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"
|
|
142
|
+
autoComplete="off"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
<div className="flex items-center justify-between gap-2">
|
|
148
|
+
<p className="text-[10px] text-agent-muted-foreground">
|
|
149
|
+
{provider === 'ddg'
|
|
150
|
+
? '免费后端走 DuckDuckGo 公开搜索页,无需 Key。质量与稳定性不如 Tavily;国内网络可能需要系统代理。Harbor 评测仍关闭网络工具。'
|
|
151
|
+
: 'GLM / OpenRouter / DeepSeek 需要这把钥;OpenAI(api.openai.com)可用聊天凭证走托管搜索,不必另配。Harbor 评测仍关闭网络工具。'}
|
|
152
|
+
</p>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={handleSave}
|
|
156
|
+
disabled={saving}
|
|
157
|
+
className={`h-8 shrink-0 px-4 rounded-full text-xs font-medium transition-all ${
|
|
158
|
+
saving
|
|
159
|
+
? 'bg-agent-muted text-agent-muted-foreground cursor-not-allowed'
|
|
160
|
+
: 'bg-agent-foreground text-agent-canvas hover:opacity-90'
|
|
161
|
+
}`}
|
|
162
|
+
>
|
|
163
|
+
{saving ? <LuLoaderCircle className="h-3 w-3 animate-spin" /> : '保存'}
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{status && (
|
|
168
|
+
<p className="text-[10px] text-agent-muted-foreground bg-agent-muted/10 px-2 py-1 rounded border border-agent-border/20">
|
|
169
|
+
{status}
|
|
170
|
+
</p>
|
|
171
|
+
)}
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{error && (
|
|
177
|
+
<div className="rounded-agent-md border border-agent-destructive/20 bg-agent-destructive/10 p-2.5 text-xs text-agent-destructive">
|
|
178
|
+
{error}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
COMPAT_AUTO,
|
|
4
|
+
formStateFromOverrides,
|
|
5
|
+
overridesFromFormState,
|
|
6
|
+
} from './compat-flags-model';
|
|
7
|
+
import type { CompatFlagDescriptor } from '@/lib/local-api';
|
|
8
|
+
|
|
9
|
+
// 与框架 compat.py `_FLAG_WIRE_SPEC` 同形的描述符夹具。
|
|
10
|
+
const FLAGS: CompatFlagDescriptor[] = [
|
|
11
|
+
{ key: 'supportsUsageInStreaming', field: 'supports_usage_in_streaming', kind: 'bool', default: true, description: '' },
|
|
12
|
+
{ key: 'maxTokensField', field: 'max_tokens_field', kind: 'enum:max_tokens,max_completion_tokens', default: 'max_tokens', description: '' },
|
|
13
|
+
{ key: 'supportsTemperature', field: 'supports_temperature', kind: 'bool', default: true, description: '' },
|
|
14
|
+
{ key: 'reasoningDeltaFields', field: 'reasoning_delta_fields', kind: 'string-list', default: ['reasoning_content', 'reasoning'], description: '' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
describe('compat-flags-model (W1.3.2)', () => {
|
|
18
|
+
it('unset overrides render as auto for every flag', () => {
|
|
19
|
+
expect(formStateFromOverrides(undefined, FLAGS)).toEqual({
|
|
20
|
+
supportsUsageInStreaming: COMPAT_AUTO,
|
|
21
|
+
maxTokensField: COMPAT_AUTO,
|
|
22
|
+
supportsTemperature: COMPAT_AUTO,
|
|
23
|
+
reasoningDeltaFields: COMPAT_AUTO,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('persisted overrides map to their form values', () => {
|
|
28
|
+
const state = formStateFromOverrides(
|
|
29
|
+
{ supportsTemperature: false, reasoningDeltaFields: ['reasoning_content'] },
|
|
30
|
+
FLAGS,
|
|
31
|
+
);
|
|
32
|
+
expect(state.supportsTemperature).toBe('false');
|
|
33
|
+
expect(state.reasoningDeltaFields).toBe('reasoning_content');
|
|
34
|
+
expect(state.maxTokensField).toBe(COMPAT_AUTO);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('all-auto form state produces no overrides (auto-detect fallback)', () => {
|
|
38
|
+
const state = formStateFromOverrides(undefined, FLAGS);
|
|
39
|
+
expect(overridesFromFormState(state, FLAGS)).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('form state builds a typed overrides payload', () => {
|
|
43
|
+
const overrides = overridesFromFormState(
|
|
44
|
+
{
|
|
45
|
+
supportsTemperature: 'false',
|
|
46
|
+
maxTokensField: 'max_completion_tokens',
|
|
47
|
+
reasoningDeltaFields: 'reasoning_content, reasoning',
|
|
48
|
+
supportsUsageInStreaming: COMPAT_AUTO,
|
|
49
|
+
},
|
|
50
|
+
FLAGS,
|
|
51
|
+
);
|
|
52
|
+
expect(overrides).toEqual({
|
|
53
|
+
supportsTemperature: false,
|
|
54
|
+
maxTokensField: 'max_completion_tokens',
|
|
55
|
+
reasoningDeltaFields: ['reasoning_content', 'reasoning'],
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('invalid enum values and empty lists are dropped', () => {
|
|
60
|
+
const overrides = overridesFromFormState(
|
|
61
|
+
{ maxTokensField: 'bogus_field', reasoningDeltaFields: ' ' },
|
|
62
|
+
FLAGS,
|
|
63
|
+
);
|
|
64
|
+
expect(overrides).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('round-trips: overrides → form → overrides', () => {
|
|
68
|
+
const original = { supportsTemperature: false as const, reasoningDeltaFields: ['reasoning'] };
|
|
69
|
+
expect(overridesFromFormState(formStateFromOverrides(original, FLAGS), FLAGS)).toEqual(original);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CompatFlagDescriptor, OpenAICompatOverrides } from '@/lib/local-api';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* compat 旗标设置区的纯状态模型——无 React、无运行时框架依赖,node 侧
|
|
5
|
+
* vitest 直接可测(与 orchestration-children-model 同一拆分理由)。
|
|
6
|
+
*
|
|
7
|
+
* 表单值统一为字符串三态:'auto'(不覆盖,走框架 URL 自动探测)|
|
|
8
|
+
* 'true'/'false'(bool 旗标)| 枚举选项 | 逗号分隔列表(string-list)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const COMPAT_AUTO = 'auto';
|
|
12
|
+
|
|
13
|
+
export type CompatFormState = Record<string, string>;
|
|
14
|
+
|
|
15
|
+
/** 已持久化的覆盖 → 表单初值;未覆盖的旗标落在 'auto'。 */
|
|
16
|
+
export function formStateFromOverrides(
|
|
17
|
+
overrides: OpenAICompatOverrides | undefined,
|
|
18
|
+
flags: CompatFlagDescriptor[],
|
|
19
|
+
): CompatFormState {
|
|
20
|
+
const state: CompatFormState = {};
|
|
21
|
+
const source = (overrides ?? {}) as Record<string, unknown>;
|
|
22
|
+
for (const flag of flags) {
|
|
23
|
+
const value = source[flag.key];
|
|
24
|
+
if (value === undefined || value === null) {
|
|
25
|
+
state[flag.key] = COMPAT_AUTO;
|
|
26
|
+
} else if (flag.kind === 'bool') {
|
|
27
|
+
state[flag.key] = value === true ? 'true' : 'false';
|
|
28
|
+
} else if (flag.kind === 'string-list') {
|
|
29
|
+
state[flag.key] = Array.isArray(value) ? value.join(', ') : COMPAT_AUTO;
|
|
30
|
+
} else {
|
|
31
|
+
state[flag.key] = String(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 表单状态 → 覆盖载荷;全部 'auto'/空时返回 undefined(= 不下发覆盖)。 */
|
|
38
|
+
export function overridesFromFormState(
|
|
39
|
+
state: CompatFormState,
|
|
40
|
+
flags: CompatFlagDescriptor[],
|
|
41
|
+
): OpenAICompatOverrides | undefined {
|
|
42
|
+
const out: Record<string, unknown> = {};
|
|
43
|
+
for (const flag of flags) {
|
|
44
|
+
const raw = (state[flag.key] ?? COMPAT_AUTO).trim();
|
|
45
|
+
if (raw === COMPAT_AUTO || raw === '') continue;
|
|
46
|
+
if (flag.kind === 'bool') {
|
|
47
|
+
if (raw === 'true' || raw === 'false') out[flag.key] = raw === 'true';
|
|
48
|
+
} else if (flag.kind === 'string-list') {
|
|
49
|
+
const list = raw.split(',').map((v) => v.trim()).filter(Boolean);
|
|
50
|
+
if (list.length > 0) out[flag.key] = list;
|
|
51
|
+
} else if (flag.kind.startsWith('enum:')) {
|
|
52
|
+
const options = flag.kind.slice('enum:'.length).split(',');
|
|
53
|
+
if (options.includes(raw)) out[flag.key] = raw;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return Object.keys(out).length > 0 ? (out as OpenAICompatOverrides) : undefined;
|
|
57
|
+
}
|