@steerable/agent-shell-web 0.6.19 → 0.6.21
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/package.json +15 -8
- package/src/components/chat/ApprovalModal.tsx +21 -41
- package/src/components/chat/ApprovalPromptProvider.tsx +98 -0
- package/src/components/chat/AskUserPromptProvider.tsx +99 -0
- package/src/components/chat/AssistantMessage.tsx +25 -2
- package/src/components/chat/ChatInput.tsx +116 -13
- package/src/components/chat/FilePathCode.tsx +65 -0
- package/src/components/chat/LocalChatPanel.tsx +19 -0
- package/src/components/chat/Markdown.tsx +54 -25
- package/src/components/chat/MessageList.tsx +66 -18
- package/src/components/chat/ModelPicker.tsx +5 -3
- package/src/components/chat/SuggestedReplies.tsx +34 -0
- package/src/components/chat/TurnFilesCard.tsx +113 -0
- package/src/components/chat/TurnProcessGroup.tsx +7 -2
- package/src/components/chat/path-mentions.ts +148 -0
- package/src/components/chat/suggested-replies-model.ts +40 -0
- package/src/components/chat/turn-files.ts +50 -0
- package/src/layouts/AgentLayout.tsx +15 -26
- package/src/lib/browser-dev-electron-mock.ts +15 -0
- package/src/lib/electron-bridge.ts +13 -7
- package/src/lib/http-bridge.ts +8 -2
- package/src/lib/local-api.ts +31 -0
- package/src/pages/AgentPage.tsx +126 -1
- package/src/pages/ChatPanelPreviewPage.tsx +4 -0
- package/vite.base.d.ts +7 -0
- package/vite.base.js +83 -0
- package/src/brand.test.ts +0 -22
- package/src/components/AgentSidebar.test.tsx +0 -791
- package/src/components/ChatHeader.test.tsx +0 -230
- package/src/components/LocalLlmSettingsModal.test.tsx +0 -372
- package/src/components/chat/ApprovalModal.test.tsx +0 -83
- package/src/components/chat/AskUserModalHost.test.tsx +0 -176
- package/src/components/chat/AskUserModalHost.tsx +0 -61
- package/src/components/chat/AskUserQuestionMenu.test.tsx +0 -190
- package/src/components/chat/AssistantMessage.test.tsx +0 -68
- package/src/components/chat/ChatInput.test.tsx +0 -609
- package/src/components/chat/ExecPolicyPicker.test.tsx +0 -26
- package/src/components/chat/Markdown.test.tsx +0 -170
- package/src/components/chat/ModelPicker.test.tsx +0 -149
- package/src/components/chat/SessionTreeModal.test.tsx +0 -161
- package/src/components/chat/TaskOutcomeCards.test.tsx +0 -97
- package/src/components/chat/TaskPanelModal.test.tsx +0 -198
- package/src/components/chat/TaskProcessPanel.test.tsx +0 -111
- package/src/components/chat/TurnProcessGroup.test.tsx +0 -161
- package/src/components/chat/elapsed.test.ts +0 -42
- package/src/components/chat/executed-actions-model.test.ts +0 -134
- package/src/components/chat/orchestration-children-model.test.ts +0 -115
- package/src/components/chat/turn-timeline.test.ts +0 -97
- package/src/components/chat/useChatTasks.test.ts +0 -136
- package/src/components/settings/AgentsSettingsPanel.test.tsx +0 -229
- package/src/components/settings/InsightsSettingsPanel.test.tsx +0 -132
- package/src/components/settings/LlmSettingsPanel.test.tsx +0 -461
- package/src/components/settings/ModelIdCombobox.test.tsx +0 -88
- package/src/components/settings/SecuritySettingsPanel.test.tsx +0 -122
- package/src/components/settings/SkillsSettingsPanel.test.tsx +0 -62
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +0 -90
- package/src/components/settings/compat-flags-model.test.ts +0 -71
- package/src/components/settings/llm-vendors.test.ts +0 -171
- package/src/components/settings/model-capabilities.test.ts +0 -88
- package/src/components/settings/preset-choice-model.test.ts +0 -96
- package/src/hooks/useChatsAndAgents.test.ts +0 -165
- package/src/lib/attachments.test.ts +0 -127
- package/src/lib/chat-title.test.ts +0 -53
- package/src/lib/chat-transport.test.ts +0 -282
- package/src/lib/date-groups.test.ts +0 -62
- package/src/lib/demo-flag.test.ts +0 -17
- package/src/lib/electron-bridge.test.ts +0 -54
- package/src/lib/exec-policy.test.ts +0 -71
- package/src/lib/http-bridge.test.ts +0 -283
- package/src/lib/insights.test.ts +0 -56
- package/src/lib/local-api.test.ts +0 -353
- package/src/lib/pending-first-message.test.ts +0 -17
- package/src/lib/slash-sources.test.ts +0 -161
- package/src/pages/AgentPage.test.tsx +0 -627
- package/src/pages/SettingsPage.test.tsx +0 -102
- package/vite.base.ts +0 -94
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ChatHeader 任务角标契约:后台任务在面板关着的时候也要能被看见,
|
|
3
|
-
* 且只有一件事等着处理时点角标要能跳过任务列表这一层。
|
|
4
|
-
* - 无任务 → 按钮保持素净,不显示计数;
|
|
5
|
-
* - 有任务 → 按钮上色 + 计数,配色按「该不该现在看一眼」取最高优先级
|
|
6
|
-
* (运行中 > 待合并 > 失败 > 全部跑完);
|
|
7
|
-
* - 唯一失败任务 → 点角标直接开推理过程,不经弹层;
|
|
8
|
-
* - 唯一待合并任务 → 点角标开弹层并展开该行(合并/丢弃按钮在那儿);
|
|
9
|
-
* - 有任务在跑或多件待处理 → 仍然先给列表,用户得先挑。
|
|
10
|
-
* 列表本身的订阅与刷新归 useChatTasks,见 chat/useChatTasks.test.ts。
|
|
11
|
-
*/
|
|
12
|
-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
13
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
-
import type { LocalChat, LocalTask } from '@/lib/local-api';
|
|
15
|
-
import { ChatHeader } from './ChatHeader';
|
|
16
|
-
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
cleanup();
|
|
19
|
-
delete (window as { electron?: unknown }).electron;
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
const CHAT: LocalChat = {
|
|
23
|
-
id: 'chat_1',
|
|
24
|
-
projectId: null,
|
|
25
|
-
userId: 'u1',
|
|
26
|
-
title: '循环任务',
|
|
27
|
-
agentId: null,
|
|
28
|
-
createdAt: '2026-09-13T01:00:00.000Z',
|
|
29
|
-
updatedAt: '2026-09-13T01:00:00.000Z',
|
|
30
|
-
isPinned: false,
|
|
31
|
-
systemPrompt: null,
|
|
32
|
-
pinnedRefs: null,
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
|
|
36
|
-
return {
|
|
37
|
-
id: 'task-1',
|
|
38
|
-
chatId: 'chat_1',
|
|
39
|
-
task: '每 5 分钟问好',
|
|
40
|
-
status: 'completed',
|
|
41
|
-
answer: '你好',
|
|
42
|
-
error: null,
|
|
43
|
-
worktreePath: null,
|
|
44
|
-
worktreeBranch: null,
|
|
45
|
-
worktreeState: null,
|
|
46
|
-
createdAt: '2026-09-13T01:00:00.000Z',
|
|
47
|
-
updatedAt: '2026-09-13T01:01:00.000Z',
|
|
48
|
-
...overrides,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** 弹层自带订阅:展开它的用例需要一个能应答 GET /tasks 的 bridge。 */
|
|
53
|
-
function installBridge(tasks: LocalTask[]) {
|
|
54
|
-
(window as { electron?: unknown }).electron = {
|
|
55
|
-
localBackend: {
|
|
56
|
-
request: vi.fn((input: { method: string; path: string }) => {
|
|
57
|
-
if (input.method === 'GET' && input.path.endsWith('/tasks')) {
|
|
58
|
-
return Promise.resolve({ tasks });
|
|
59
|
-
}
|
|
60
|
-
return Promise.reject(new Error(`unexpected request: ${input.path}`));
|
|
61
|
-
}),
|
|
62
|
-
},
|
|
63
|
-
onTaskUpdated: () => () => {},
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function taskButton() {
|
|
68
|
-
return screen.getByRole('button', { name: /后台任务/ });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
describe('ChatHeader 后台任务角标', () => {
|
|
72
|
-
it('没有任务时不显示计数', () => {
|
|
73
|
-
render(<ChatHeader chat={CHAT} agent={null} tasks={[]} />);
|
|
74
|
-
|
|
75
|
-
expect(taskButton().getAttribute('data-task-state')).toBeNull();
|
|
76
|
-
expect(taskButton().textContent).toBe('');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('有运行中的任务时显示运行中计数并标成 running', () => {
|
|
80
|
-
render(
|
|
81
|
-
<ChatHeader
|
|
82
|
-
chat={CHAT}
|
|
83
|
-
agent={null}
|
|
84
|
-
tasks={[
|
|
85
|
-
makeTask({ id: 'a', status: 'running' }),
|
|
86
|
-
makeTask({ id: 'b', status: 'running' }),
|
|
87
|
-
makeTask({ id: 'c', status: 'completed' }),
|
|
88
|
-
]}
|
|
89
|
-
/>,
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
expect(taskButton().getAttribute('data-task-state')).toBe('running');
|
|
93
|
-
expect(taskButton().textContent).toBe('2');
|
|
94
|
-
expect(taskButton().getAttribute('title')).toBe('共 3 个后台任务(2 个运行中)');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('等依赖的任务也算在推进中', () => {
|
|
98
|
-
render(
|
|
99
|
-
<ChatHeader chat={CHAT} agent={null} tasks={[makeTask({ id: 'a', status: 'blocked' })]} />,
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
expect(taskButton().getAttribute('data-task-state')).toBe('running');
|
|
103
|
-
expect(taskButton().getAttribute('title')).toBe('共 1 个后台任务(1 个等依赖)');
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('待合并的 worktree 任务优先于失败任务上色', () => {
|
|
107
|
-
render(
|
|
108
|
-
<ChatHeader
|
|
109
|
-
chat={CHAT}
|
|
110
|
-
agent={null}
|
|
111
|
-
tasks={[
|
|
112
|
-
makeTask({ id: 'a', status: 'completed', worktreeState: 'pending' }),
|
|
113
|
-
makeTask({ id: 'b', status: 'failed', error: '炸了' }),
|
|
114
|
-
]}
|
|
115
|
-
/>,
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
expect(taskButton().getAttribute('data-task-state')).toBe('review');
|
|
119
|
-
expect(taskButton().textContent).toBe('1');
|
|
120
|
-
expect(taskButton().getAttribute('title')).toBe('共 2 个后台任务(1 个待合并,1 个失败)');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('只有失败任务时标成 failed', () => {
|
|
124
|
-
render(
|
|
125
|
-
<ChatHeader
|
|
126
|
-
chat={CHAT}
|
|
127
|
-
agent={null}
|
|
128
|
-
tasks={[makeTask({ id: 'a', status: 'failed', error: '炸了' })]}
|
|
129
|
-
/>,
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
expect(taskButton().getAttribute('data-task-state')).toBe('failed');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('全部跑完时显示总数,不再抢注意力', () => {
|
|
136
|
-
render(
|
|
137
|
-
<ChatHeader chat={CHAT} agent={null} tasks={[makeTask({ id: 'a' }), makeTask({ id: 'b' })]} />,
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
expect(taskButton().getAttribute('data-task-state')).toBe('idle');
|
|
141
|
-
expect(taskButton().textContent).toBe('2');
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
describe('ChatHeader 角标直达', () => {
|
|
146
|
-
it('唯一失败任务:点角标直接开推理过程,不开弹层', () => {
|
|
147
|
-
const onInspectTask = vi.fn();
|
|
148
|
-
render(
|
|
149
|
-
<ChatHeader
|
|
150
|
-
chat={CHAT}
|
|
151
|
-
agent={null}
|
|
152
|
-
onInspectTask={onInspectTask}
|
|
153
|
-
tasks={[makeTask({ id: 'a', status: 'failed', task: '跑测试', error: '炸了' })]}
|
|
154
|
-
/>,
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
expect(taskButton().getAttribute('data-task-shortcut')).toBe('process');
|
|
158
|
-
expect(taskButton().getAttribute('title')).toContain('点击查看推理过程');
|
|
159
|
-
|
|
160
|
-
fireEvent.click(taskButton());
|
|
161
|
-
|
|
162
|
-
expect(onInspectTask).toHaveBeenCalledWith({
|
|
163
|
-
id: 'a',
|
|
164
|
-
chatId: 'chat_1',
|
|
165
|
-
title: '跑测试',
|
|
166
|
-
});
|
|
167
|
-
expect(document.querySelector('[data-task-panel-modal]')).toBeNull();
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('唯一待合并任务:点角标开弹层并展开该行', async () => {
|
|
171
|
-
const task = makeTask({
|
|
172
|
-
id: 'a',
|
|
173
|
-
status: 'completed',
|
|
174
|
-
worktreeState: 'pending',
|
|
175
|
-
worktreePath: '/tmp/wt',
|
|
176
|
-
worktreeBranch: 'steerable/a',
|
|
177
|
-
});
|
|
178
|
-
installBridge([task]);
|
|
179
|
-
render(<ChatHeader chat={CHAT} agent={null} onInspectTask={vi.fn()} tasks={[task]} />);
|
|
180
|
-
|
|
181
|
-
expect(taskButton().getAttribute('data-task-shortcut')).toBe('expand');
|
|
182
|
-
expect(taskButton().getAttribute('title')).toContain('点击处理 worktree');
|
|
183
|
-
|
|
184
|
-
fireEvent.click(taskButton());
|
|
185
|
-
|
|
186
|
-
// 展开态的标志:worktree 的「合并到主仓」按钮直接可见,不用再点一次。
|
|
187
|
-
await waitFor(() => expect(document.querySelector('[data-task-merge]')).not.toBeNull());
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('有任务在跑时不直达——先给列表', () => {
|
|
191
|
-
const onInspectTask = vi.fn();
|
|
192
|
-
render(
|
|
193
|
-
<ChatHeader
|
|
194
|
-
chat={CHAT}
|
|
195
|
-
agent={null}
|
|
196
|
-
onInspectTask={onInspectTask}
|
|
197
|
-
tasks={[
|
|
198
|
-
makeTask({ id: 'a', status: 'failed', error: '炸了' }),
|
|
199
|
-
makeTask({ id: 'b', status: 'running' }),
|
|
200
|
-
]}
|
|
201
|
-
/>,
|
|
202
|
-
);
|
|
203
|
-
|
|
204
|
-
expect(taskButton().getAttribute('data-task-shortcut')).toBeNull();
|
|
205
|
-
expect(taskButton().getAttribute('title')).not.toContain('点击');
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('多件待处理时不直达——用户得先挑', () => {
|
|
209
|
-
const onInspectTask = vi.fn();
|
|
210
|
-
installBridge([]);
|
|
211
|
-
render(
|
|
212
|
-
<ChatHeader
|
|
213
|
-
chat={CHAT}
|
|
214
|
-
agent={null}
|
|
215
|
-
onInspectTask={onInspectTask}
|
|
216
|
-
tasks={[
|
|
217
|
-
makeTask({ id: 'a', status: 'failed', error: '炸了' }),
|
|
218
|
-
makeTask({ id: 'b', status: 'failed', error: '也炸了' }),
|
|
219
|
-
]}
|
|
220
|
-
/>,
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
expect(taskButton().getAttribute('data-task-shortcut')).toBeNull();
|
|
224
|
-
|
|
225
|
-
fireEvent.click(taskButton());
|
|
226
|
-
|
|
227
|
-
expect(onInspectTask).not.toHaveBeenCalled();
|
|
228
|
-
expect(document.querySelector('[data-task-panel-modal]')).not.toBeNull();
|
|
229
|
-
});
|
|
230
|
-
});
|
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LocalLlmSettingsModal 的厂商参数预制交互(R16 配置入口):
|
|
3
|
-
* - openai-compat 显示预制区,ollama 不显示;
|
|
4
|
-
* - 「自动」档按 baseUrl+model 向框架 resolve 并预览命中参数;
|
|
5
|
-
* - 「关闭」保存 {enabled:false};「指定预制」下拉选中后保存 override;
|
|
6
|
-
* - Temperature 自动/手动切换:自动 = 不下发(undefined),手动滑杆持久化显式值。
|
|
7
|
-
* 进程/wire 层由 tests/e2e/presets.e2e.test.ts 覆盖;这里验用户看得见的交互。
|
|
8
|
-
*/
|
|
9
|
-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
-
import type { LlmSettings, ProviderPresetDescriptor } from '@/lib/local-api';
|
|
12
|
-
|
|
13
|
-
const getLlmSettings = vi.fn();
|
|
14
|
-
const setLlmSettings = vi.fn();
|
|
15
|
-
const getCompatFlags = vi.fn();
|
|
16
|
-
const getProviderPresets = vi.fn();
|
|
17
|
-
const resolveProviderPreset = vi.fn();
|
|
18
|
-
const getCatalogProviders = vi.fn();
|
|
19
|
-
const getLlmModels = vi.fn();
|
|
20
|
-
|
|
21
|
-
vi.mock('@/lib/electron-bridge', () => ({
|
|
22
|
-
isElectron: () => true,
|
|
23
|
-
getElectronBridge: () => undefined,
|
|
24
|
-
}));
|
|
25
|
-
vi.mock('@/lib/local-api', () => ({
|
|
26
|
-
getLlmSettings: () => getLlmSettings(),
|
|
27
|
-
setLlmSettings: (s: LlmSettings) => setLlmSettings(s),
|
|
28
|
-
getCompatFlags: () => getCompatFlags(),
|
|
29
|
-
getProviderPresets: () => getProviderPresets(),
|
|
30
|
-
resolveProviderPreset: (baseUrl?: string, model?: string) =>
|
|
31
|
-
resolveProviderPreset(baseUrl, model),
|
|
32
|
-
getCatalogProviders: () => getCatalogProviders(),
|
|
33
|
-
getLlmModels: (draft?: {
|
|
34
|
-
baseUrl?: string;
|
|
35
|
-
apiKey?: string;
|
|
36
|
-
provider?: string;
|
|
37
|
-
refresh?: boolean;
|
|
38
|
-
}) => getLlmModels(draft),
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
const { LocalLlmSettingsModal } = await import('./LocalLlmSettingsModal');
|
|
42
|
-
|
|
43
|
-
const DEEPSEEK: ProviderPresetDescriptor = {
|
|
44
|
-
host: 'api.deepseek.com',
|
|
45
|
-
modelPrefix: 'deepseek',
|
|
46
|
-
temperature: 0.0,
|
|
47
|
-
topP: null,
|
|
48
|
-
maxTokens: null,
|
|
49
|
-
reasoningEffort: null,
|
|
50
|
-
extraBody: null,
|
|
51
|
-
};
|
|
52
|
-
const QWEN: ProviderPresetDescriptor = {
|
|
53
|
-
host: null,
|
|
54
|
-
modelPrefix: 'qwen3',
|
|
55
|
-
temperature: 0.6,
|
|
56
|
-
topP: 0.95,
|
|
57
|
-
maxTokens: null,
|
|
58
|
-
reasoningEffort: null,
|
|
59
|
-
extraBody: { top_k: 20 },
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const BASE_SETTINGS: LlmSettings = {
|
|
63
|
-
provider: 'openai-compat',
|
|
64
|
-
model: 'deepseek-chat',
|
|
65
|
-
baseUrl: 'https://api.deepseek.com',
|
|
66
|
-
apiKey: 'sk-test',
|
|
67
|
-
maxTotalTokens: 60000,
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
function presetSection(): HTMLElement {
|
|
71
|
-
return screen.getByText('厂商参数预制').closest('div')!.parentElement as HTMLElement;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function renderOpen(settings: Partial<LlmSettings> = {}) {
|
|
75
|
-
getLlmSettings.mockResolvedValue({ ...BASE_SETTINGS, ...settings });
|
|
76
|
-
const utils = render(<LocalLlmSettingsModal open onClose={() => {}} />);
|
|
77
|
-
// 等 reload 完成:预制区出现即数据就绪。
|
|
78
|
-
await screen.findByText('厂商参数预制');
|
|
79
|
-
return utils;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
beforeEach(() => {
|
|
83
|
-
vi.clearAllMocks();
|
|
84
|
-
getCompatFlags.mockResolvedValue({ flags: [] });
|
|
85
|
-
getProviderPresets.mockResolvedValue({ presets: [DEEPSEEK, QWEN] });
|
|
86
|
-
resolveProviderPreset.mockResolvedValue({ preset: { temperature: 0.0 } });
|
|
87
|
-
getCatalogProviders.mockResolvedValue({
|
|
88
|
-
providers: [
|
|
89
|
-
{
|
|
90
|
-
id: 'deepseek',
|
|
91
|
-
apiBaseUrl: 'https://api.deepseek.com',
|
|
92
|
-
envVars: ['DEEPSEEK_API_KEY'],
|
|
93
|
-
wireKind: 'openai_compat',
|
|
94
|
-
models: ['deepseek-chat', 'deepseek-v4-flash'],
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
id: 'anthropic',
|
|
98
|
-
apiBaseUrl: 'https://api.anthropic.com',
|
|
99
|
-
envVars: ['ANTHROPIC_API_KEY'],
|
|
100
|
-
wireKind: 'anthropic',
|
|
101
|
-
models: ['claude-sonnet-4-6', 'claude-opus-4-7'],
|
|
102
|
-
},
|
|
103
|
-
],
|
|
104
|
-
});
|
|
105
|
-
getLlmModels.mockResolvedValue({ models: [], catalogStatus: 'offline' });
|
|
106
|
-
setLlmSettings.mockImplementation(async (s: LlmSettings) => s);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
afterEach(cleanup);
|
|
110
|
-
|
|
111
|
-
describe('LocalLlmSettingsModal — 遮罩关闭', () => {
|
|
112
|
-
it('点在遮罩上关闭;面板内按下后在遮罩弹起不关', async () => {
|
|
113
|
-
const onClose = vi.fn();
|
|
114
|
-
getLlmSettings.mockResolvedValue(BASE_SETTINGS);
|
|
115
|
-
render(<LocalLlmSettingsModal open onClose={onClose} />);
|
|
116
|
-
const dialog = await screen.findByTestId('llm-settings-dialog');
|
|
117
|
-
const panel = dialog.firstElementChild as HTMLElement;
|
|
118
|
-
|
|
119
|
-
fireEvent.mouseDown(panel);
|
|
120
|
-
fireEvent.mouseUp(dialog);
|
|
121
|
-
fireEvent.click(dialog);
|
|
122
|
-
expect(onClose).not.toHaveBeenCalled();
|
|
123
|
-
|
|
124
|
-
fireEvent.mouseDown(dialog);
|
|
125
|
-
expect(onClose).toHaveBeenCalledTimes(1);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe('LocalLlmSettingsModal — 厂商参数预制', () => {
|
|
130
|
-
it('openai-compat 显示预制区;ollama 不显示', async () => {
|
|
131
|
-
const { unmount } = await renderOpen();
|
|
132
|
-
expect(screen.getByText('厂商参数预制')).toBeTruthy();
|
|
133
|
-
expect(screen.queryByText('本地技能管理')).toBeNull();
|
|
134
|
-
expect(screen.queryByText('MCP 服务')).toBeNull();
|
|
135
|
-
unmount();
|
|
136
|
-
|
|
137
|
-
getLlmSettings.mockResolvedValue({
|
|
138
|
-
...BASE_SETTINGS,
|
|
139
|
-
provider: 'ollama',
|
|
140
|
-
model: 'llama3.1:8b',
|
|
141
|
-
baseUrl: 'http://127.0.0.1:11434',
|
|
142
|
-
});
|
|
143
|
-
render(<LocalLlmSettingsModal open onClose={() => {}} />);
|
|
144
|
-
await screen.findByText('Temperature');
|
|
145
|
-
expect(screen.queryByText('厂商参数预制')).toBeNull();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('「自动」档预览命中参数,保存不写 presets(注册表自动匹配)', async () => {
|
|
149
|
-
await renderOpen();
|
|
150
|
-
// 防抖 300ms 后展示 resolve 结果。
|
|
151
|
-
const preview = await screen.findByText(/命中预制:temperature 0/, undefined, {
|
|
152
|
-
timeout: 3000,
|
|
153
|
-
});
|
|
154
|
-
expect(preview).toBeTruthy();
|
|
155
|
-
expect(resolveProviderPreset).toHaveBeenCalledWith(
|
|
156
|
-
'https://api.deepseek.com',
|
|
157
|
-
'deepseek-chat',
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
fireEvent.click(screen.getByText('保存'));
|
|
161
|
-
await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
|
|
162
|
-
expect(setLlmSettings.mock.calls[0][0].presets).toBeUndefined();
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('「关闭」保存 {enabled:false},预览说明不下发', async () => {
|
|
166
|
-
await renderOpen();
|
|
167
|
-
fireEvent.click(document.querySelector('[data-preset-mode="off"]')!);
|
|
168
|
-
expect(screen.getByText('已关闭:不下发厂商预制参数。')).toBeTruthy();
|
|
169
|
-
|
|
170
|
-
fireEvent.click(screen.getByText('保存'));
|
|
171
|
-
await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
|
|
172
|
-
expect(setLlmSettings.mock.calls[0][0].presets).toEqual({ enabled: false });
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('「指定预制」下拉选中注册表条目,保存其 override', async () => {
|
|
176
|
-
await renderOpen();
|
|
177
|
-
fireEvent.click(document.querySelector('[data-preset-mode="pinned"]')!);
|
|
178
|
-
|
|
179
|
-
const select = presetSection().querySelector('select')!;
|
|
180
|
-
expect(select).toBeTruthy();
|
|
181
|
-
// 选中 qwen3 行(注册表第二个条目)。
|
|
182
|
-
fireEvent.change(select, { target: { value: '1' } });
|
|
183
|
-
expect(
|
|
184
|
-
screen.getByText(/生效参数:temperature 0.6 · top_p 0.95 · top_k 20/),
|
|
185
|
-
).toBeTruthy();
|
|
186
|
-
|
|
187
|
-
fireEvent.click(screen.getByText('保存'));
|
|
188
|
-
await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
|
|
189
|
-
expect(setLlmSettings.mock.calls[0][0].presets).toEqual({
|
|
190
|
-
override: { temperature: 0.6, topP: 0.95, extraBody: { top_k: 20 } },
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('已保存的钉死覆盖在注册表命中时回选对应行', async () => {
|
|
195
|
-
await renderOpen({
|
|
196
|
-
presets: { override: { temperature: 0.6, topP: 0.95, extraBody: { top_k: 20 } } },
|
|
197
|
-
});
|
|
198
|
-
// pinned 档直接展示下拉且选中 qwen3 行(下标 1)。
|
|
199
|
-
const select = presetSection().querySelector('select')!;
|
|
200
|
-
expect(select.value).toBe('1');
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it('Temperature 默认自动(无滑杆、保存不下发),切手动出滑杆并持久化', async () => {
|
|
204
|
-
await renderOpen();
|
|
205
|
-
// 自动:提示文案在,滑杆不在;命中预览带入当前温度。
|
|
206
|
-
await screen.findByText(/自动:命中厂商预制时用预制温度/, undefined, {
|
|
207
|
-
timeout: 3000,
|
|
208
|
-
});
|
|
209
|
-
await screen.findByText(/(当前命中:0)/, undefined, { timeout: 3000 });
|
|
210
|
-
expect(document.querySelector('input[type="range"]')).toBeNull();
|
|
211
|
-
|
|
212
|
-
fireEvent.click(screen.getByText('保存'));
|
|
213
|
-
await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
|
|
214
|
-
expect(setLlmSettings.mock.calls[0][0].temperature).toBeUndefined();
|
|
215
|
-
|
|
216
|
-
// 手动:滑杆出现,初值取命中预制的温度(0.0)。
|
|
217
|
-
setLlmSettings.mockClear();
|
|
218
|
-
fireEvent.click(document.querySelector('[data-temperature-mode="manual"]')!);
|
|
219
|
-
const slider = document.querySelector('input[type="range"]') as HTMLInputElement;
|
|
220
|
-
expect(slider).toBeTruthy();
|
|
221
|
-
expect(slider.value).toBe('0');
|
|
222
|
-
|
|
223
|
-
fireEvent.change(slider, { target: { value: '0.5' } });
|
|
224
|
-
fireEvent.click(screen.getByText('保存'));
|
|
225
|
-
await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
|
|
226
|
-
expect(setLlmSettings.mock.calls[0][0].temperature).toBe(0.5);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('选择 Anthropic 自动填 URL 并隐藏 OpenAI 预制区', async () => {
|
|
230
|
-
await renderOpen();
|
|
231
|
-
fireEvent.change(screen.getByTestId('llm-vendor-select'), { target: { value: 'anthropic' } });
|
|
232
|
-
const url = screen.getByPlaceholderText('https://api.anthropic.com') as HTMLInputElement;
|
|
233
|
-
expect(url.value).toBe('https://api.anthropic.com');
|
|
234
|
-
expect(screen.queryByText('厂商参数预制')).toBeNull();
|
|
235
|
-
expect((screen.getByTestId('llm-model-input') as HTMLInputElement).value).toBe(
|
|
236
|
-
'claude-sonnet-4-6',
|
|
237
|
-
);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('选择自定义后 URL 留空供手填', async () => {
|
|
241
|
-
await renderOpen();
|
|
242
|
-
fireEvent.change(screen.getByTestId('llm-vendor-select'), { target: { value: 'custom' } });
|
|
243
|
-
const url = screen.getByPlaceholderText('https://your-gateway.example/v1') as HTMLInputElement;
|
|
244
|
-
expect(url.value).toBe('');
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('保存时写入 vendorId 与对应的 sidecar provider', async () => {
|
|
248
|
-
await renderOpen();
|
|
249
|
-
fireEvent.change(screen.getByTestId('llm-vendor-select'), { target: { value: 'anthropic' } });
|
|
250
|
-
fireEvent.click(screen.getByText('保存'));
|
|
251
|
-
await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
|
|
252
|
-
expect(setLlmSettings.mock.calls[0][0]).toMatchObject({
|
|
253
|
-
provider: 'anthropic',
|
|
254
|
-
vendorId: 'anthropic',
|
|
255
|
-
baseUrl: 'https://api.anthropic.com',
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('模型下拉以网关实时目录为准,不混入服务商内置列表', async () => {
|
|
260
|
-
getLlmModels.mockResolvedValue({
|
|
261
|
-
models: [{ id: 'deepseek-chat' }, { id: 'deepseek-reasoner' }],
|
|
262
|
-
catalogStatus: 'live',
|
|
263
|
-
});
|
|
264
|
-
await renderOpen();
|
|
265
|
-
await waitFor(() =>
|
|
266
|
-
expect(screen.getByText(/已从网关拉取 2 个模型/)).toBeTruthy(),
|
|
267
|
-
);
|
|
268
|
-
fireEvent.focus(screen.getByTestId('llm-model-input'));
|
|
269
|
-
expect(screen.getByRole('option', { name: 'deepseek-reasoner' })).toBeTruthy();
|
|
270
|
-
expect(screen.queryByRole('option', { name: 'deepseek-v4-flash' })).toBeNull();
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('刷新按钮绕过缓存重新拉取网关目录', async () => {
|
|
274
|
-
getLlmModels.mockResolvedValue({
|
|
275
|
-
models: [{ id: 'deepseek-chat' }],
|
|
276
|
-
catalogStatus: 'live',
|
|
277
|
-
});
|
|
278
|
-
await renderOpen();
|
|
279
|
-
await waitFor(() =>
|
|
280
|
-
expect(screen.getByText(/已从网关拉取 1 个模型/)).toBeTruthy(),
|
|
281
|
-
);
|
|
282
|
-
getLlmModels.mockClear();
|
|
283
|
-
getLlmModels.mockResolvedValue({
|
|
284
|
-
models: [{ id: 'deepseek-flash' }, { id: 'deepseek-v4-pro' }],
|
|
285
|
-
catalogStatus: 'live',
|
|
286
|
-
});
|
|
287
|
-
fireEvent.click(screen.getByTestId('llm-model-refresh'));
|
|
288
|
-
await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
|
|
289
|
-
expect(getLlmModels.mock.calls.at(-1)?.[0]).toMatchObject({ refresh: true });
|
|
290
|
-
await waitFor(() =>
|
|
291
|
-
expect(screen.getByText(/已从网关拉取 2 个模型/)).toBeTruthy(),
|
|
292
|
-
);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('测试按钮用当前凭证验证网关并回报结果', async () => {
|
|
296
|
-
getLlmModels.mockResolvedValue({
|
|
297
|
-
models: [{ id: 'deepseek-chat' }],
|
|
298
|
-
catalogStatus: 'live',
|
|
299
|
-
});
|
|
300
|
-
await renderOpen();
|
|
301
|
-
await waitFor(() => expect(screen.getByTestId('llm-key-test')).toBeTruthy());
|
|
302
|
-
getLlmModels.mockClear();
|
|
303
|
-
getLlmModels.mockResolvedValue({
|
|
304
|
-
models: [{ id: 'deepseek-flash' }, { id: 'deepseek-v4-pro' }],
|
|
305
|
-
catalogStatus: 'live',
|
|
306
|
-
});
|
|
307
|
-
fireEvent.click(screen.getByTestId('llm-key-test'));
|
|
308
|
-
await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
|
|
309
|
-
expect(getLlmModels.mock.calls.at(-1)?.[0]).toMatchObject({ refresh: true });
|
|
310
|
-
await waitFor(() =>
|
|
311
|
-
expect(screen.getByText(/凭证可用,网关返回 2 个模型/)).toBeTruthy(),
|
|
312
|
-
);
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it('测试失败时展示网关错误', async () => {
|
|
316
|
-
getLlmModels.mockResolvedValue({
|
|
317
|
-
models: [],
|
|
318
|
-
catalogStatus: 'offline',
|
|
319
|
-
error: '401 unauthorized',
|
|
320
|
-
});
|
|
321
|
-
await renderOpen();
|
|
322
|
-
await waitFor(() => expect(screen.getByTestId('llm-key-test')).toBeTruthy());
|
|
323
|
-
fireEvent.click(screen.getByTestId('llm-key-test'));
|
|
324
|
-
await waitFor(() =>
|
|
325
|
-
expect(screen.getByText(/验证失败:401 unauthorized/)).toBeTruthy(),
|
|
326
|
-
);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it('选中模型展示核实过的思考档位,未 join 的 id 只标未识别', async () => {
|
|
330
|
-
getLlmSettings.mockResolvedValue({
|
|
331
|
-
...BASE_SETTINGS,
|
|
332
|
-
model: 'deepseek-v4-pro',
|
|
333
|
-
});
|
|
334
|
-
getLlmModels.mockResolvedValue({
|
|
335
|
-
models: [
|
|
336
|
-
{
|
|
337
|
-
id: 'deepseek-v4-pro',
|
|
338
|
-
name: 'DeepSeek V4 Pro',
|
|
339
|
-
window: 1_000_000,
|
|
340
|
-
modalities: ['text'],
|
|
341
|
-
reasoningLevels: ['high', 'max'],
|
|
342
|
-
pricing: null,
|
|
343
|
-
joinedFrom: 'deepseek/deepseek-v4-pro',
|
|
344
|
-
capabilities: 'known',
|
|
345
|
-
},
|
|
346
|
-
{
|
|
347
|
-
id: 'deepseek-flash',
|
|
348
|
-
name: null,
|
|
349
|
-
window: 131_072,
|
|
350
|
-
modalities: ['text'],
|
|
351
|
-
reasoningLevels: [],
|
|
352
|
-
pricing: null,
|
|
353
|
-
joinedFrom: null,
|
|
354
|
-
capabilities: 'unknown',
|
|
355
|
-
},
|
|
356
|
-
],
|
|
357
|
-
catalogStatus: 'live',
|
|
358
|
-
});
|
|
359
|
-
await renderOpen({ model: 'deepseek-v4-pro' });
|
|
360
|
-
const summary = await screen.findByTestId('llm-model-capabilities');
|
|
361
|
-
expect(summary.textContent).toContain('思考(high / max)');
|
|
362
|
-
expect(summary.textContent).toContain('1M');
|
|
363
|
-
expect(summary.textContent).not.toContain('未识别');
|
|
364
|
-
|
|
365
|
-
fireEvent.focus(screen.getByTestId('llm-model-input'));
|
|
366
|
-
fireEvent.click(screen.getByRole('option', { name: 'deepseek-flash' }));
|
|
367
|
-
await waitFor(() =>
|
|
368
|
-
expect(screen.getByTestId('llm-model-capabilities').textContent).toContain('未识别'),
|
|
369
|
-
);
|
|
370
|
-
expect(screen.getByTestId('llm-model-capabilities').textContent).not.toContain('131K');
|
|
371
|
-
});
|
|
372
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ApprovalModalHost 的网络出口(network_egress)分支契约(W-egress-ask):
|
|
3
|
-
* - category=network_egress 的请示以「请求访问外网」标题 + host:port
|
|
4
|
-
* 展示,并带仿冒域名警示;
|
|
5
|
-
* - 「始终」变体对网络出口隐藏(代理白名单是会话寿命的进程级状态,
|
|
6
|
-
* 持久放行要走设置的出网白名单,不是这个模态);
|
|
7
|
-
* - 普通工具调用的 7 变体行为不回归。
|
|
8
|
-
*/
|
|
9
|
-
import { act, cleanup, render, screen } from '@testing-library/react';
|
|
10
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
-
import type { ApprovalPromptRequest } from '@/lib/electron-bridge';
|
|
12
|
-
import { ApprovalModalHost } from './ApprovalModal';
|
|
13
|
-
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
cleanup();
|
|
16
|
-
delete (window as { electron?: unknown }).electron;
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
function installBridge() {
|
|
20
|
-
const listeners = new Set<(request: ApprovalPromptRequest) => void>();
|
|
21
|
-
const decide = vi.fn().mockResolvedValue(undefined);
|
|
22
|
-
(window as { electron?: unknown }).electron = {
|
|
23
|
-
approval: {
|
|
24
|
-
onRequest: (callback: (request: ApprovalPromptRequest) => void) => {
|
|
25
|
-
listeners.add(callback);
|
|
26
|
-
return () => listeners.delete(callback);
|
|
27
|
-
},
|
|
28
|
-
decide,
|
|
29
|
-
},
|
|
30
|
-
};
|
|
31
|
-
return {
|
|
32
|
-
decide,
|
|
33
|
-
emit: (request: ApprovalPromptRequest) => {
|
|
34
|
-
for (const callback of Array.from(listeners)) callback(request);
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const EGRESS_REQUEST: ApprovalPromptRequest = {
|
|
40
|
-
requestId: 'req-egress',
|
|
41
|
-
toolName: 'network_egress',
|
|
42
|
-
arguments: { host: 'cdn.example.com', port: 443, url: 'https://cdn.example.com/x.js' },
|
|
43
|
-
mode: 'other',
|
|
44
|
-
category: 'network_egress',
|
|
45
|
-
round: 0,
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const SHELL_REQUEST: ApprovalPromptRequest = {
|
|
49
|
-
requestId: 'req-shell',
|
|
50
|
-
toolName: 'bash',
|
|
51
|
-
arguments: { command: 'rm -rf /tmp/x' },
|
|
52
|
-
mode: 'destructive',
|
|
53
|
-
category: 'bash',
|
|
54
|
-
round: 0,
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
describe('ApprovalModalHost 网络出口分支(W-egress-ask)', () => {
|
|
58
|
-
it('以 host:port 标题 + 仿冒警示展示,并隐藏「始终」变体', () => {
|
|
59
|
-
const bridge = installBridge();
|
|
60
|
-
render(<ApprovalModalHost />);
|
|
61
|
-
act(() => bridge.emit(EGRESS_REQUEST));
|
|
62
|
-
|
|
63
|
-
expect(screen.getByText('Agent 请求访问外网')).toBeTruthy();
|
|
64
|
-
expect(screen.getByText('cdn.example.com:443')).toBeTruthy();
|
|
65
|
-
expect(screen.getByText(/仿冒域名/)).toBeTruthy();
|
|
66
|
-
// 会话档保留,「始终」档隐藏。
|
|
67
|
-
expect(screen.getByText('允许一次')).toBeTruthy();
|
|
68
|
-
expect(screen.getByText('本次会话允许')).toBeTruthy();
|
|
69
|
-
expect(screen.queryByText('始终允许')).toBeNull();
|
|
70
|
-
expect(screen.queryByText('始终拒绝')).toBeNull();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('普通工具调用的 7 变体不回归', () => {
|
|
74
|
-
const bridge = installBridge();
|
|
75
|
-
render(<ApprovalModalHost />);
|
|
76
|
-
act(() => bridge.emit(SHELL_REQUEST));
|
|
77
|
-
|
|
78
|
-
expect(screen.getByText('Agent 请求执行')).toBeTruthy();
|
|
79
|
-
expect(screen.getByText('始终允许')).toBeTruthy();
|
|
80
|
-
expect(screen.getByText('始终拒绝')).toBeTruthy();
|
|
81
|
-
expect(screen.getByText(/工作区沙箱/)).toBeTruthy();
|
|
82
|
-
});
|
|
83
|
-
});
|