@steerable/agent-shell-web 0.6.18 → 0.6.20

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 (55) hide show
  1. package/package.json +15 -8
  2. package/src/lib/chat-transport.ts +1 -2
  3. package/src/lib/electron-bridge.ts +2 -2
  4. package/src/lib/http-bridge.ts +18 -4
  5. package/vite.base.d.ts +7 -0
  6. package/vite.base.js +83 -0
  7. package/src/brand.test.ts +0 -22
  8. package/src/components/AgentSidebar.test.tsx +0 -791
  9. package/src/components/ChatHeader.test.tsx +0 -230
  10. package/src/components/LocalLlmSettingsModal.test.tsx +0 -372
  11. package/src/components/chat/ApprovalModal.test.tsx +0 -83
  12. package/src/components/chat/AskUserModalHost.test.tsx +0 -176
  13. package/src/components/chat/AskUserQuestionMenu.test.tsx +0 -190
  14. package/src/components/chat/AssistantMessage.test.tsx +0 -68
  15. package/src/components/chat/ChatInput.test.tsx +0 -609
  16. package/src/components/chat/ExecPolicyPicker.test.tsx +0 -26
  17. package/src/components/chat/Markdown.test.tsx +0 -170
  18. package/src/components/chat/ModelPicker.test.tsx +0 -149
  19. package/src/components/chat/SessionTreeModal.test.tsx +0 -161
  20. package/src/components/chat/TaskOutcomeCards.test.tsx +0 -97
  21. package/src/components/chat/TaskPanelModal.test.tsx +0 -198
  22. package/src/components/chat/TaskProcessPanel.test.tsx +0 -111
  23. package/src/components/chat/TurnProcessGroup.test.tsx +0 -161
  24. package/src/components/chat/elapsed.test.ts +0 -42
  25. package/src/components/chat/executed-actions-model.test.ts +0 -134
  26. package/src/components/chat/orchestration-children-model.test.ts +0 -115
  27. package/src/components/chat/turn-timeline.test.ts +0 -97
  28. package/src/components/chat/useChatTasks.test.ts +0 -136
  29. package/src/components/settings/AgentsSettingsPanel.test.tsx +0 -229
  30. package/src/components/settings/InsightsSettingsPanel.test.tsx +0 -132
  31. package/src/components/settings/LlmSettingsPanel.test.tsx +0 -461
  32. package/src/components/settings/ModelIdCombobox.test.tsx +0 -88
  33. package/src/components/settings/SecuritySettingsPanel.test.tsx +0 -122
  34. package/src/components/settings/SkillsSettingsPanel.test.tsx +0 -62
  35. package/src/components/settings/WebSearchSettingsPanel.test.tsx +0 -90
  36. package/src/components/settings/compat-flags-model.test.ts +0 -71
  37. package/src/components/settings/llm-vendors.test.ts +0 -171
  38. package/src/components/settings/model-capabilities.test.ts +0 -88
  39. package/src/components/settings/preset-choice-model.test.ts +0 -96
  40. package/src/hooks/useChatsAndAgents.test.ts +0 -165
  41. package/src/lib/attachments.test.ts +0 -127
  42. package/src/lib/chat-title.test.ts +0 -53
  43. package/src/lib/chat-transport.test.ts +0 -277
  44. package/src/lib/date-groups.test.ts +0 -62
  45. package/src/lib/demo-flag.test.ts +0 -17
  46. package/src/lib/electron-bridge.test.ts +0 -54
  47. package/src/lib/exec-policy.test.ts +0 -71
  48. package/src/lib/http-bridge.test.ts +0 -246
  49. package/src/lib/insights.test.ts +0 -56
  50. package/src/lib/local-api.test.ts +0 -353
  51. package/src/lib/pending-first-message.test.ts +0 -17
  52. package/src/lib/slash-sources.test.ts +0 -161
  53. package/src/pages/AgentPage.test.tsx +0 -627
  54. package/src/pages/SettingsPage.test.tsx +0 -102
  55. package/vite.base.ts +0 -94
@@ -1,132 +0,0 @@
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
- local: { saveTextFile: vi.fn() },
11
- }),
12
- }));
13
-
14
- const { InsightsConsentBanner, InsightsSettingsPanel } = await import('./InsightsSettingsPanel');
15
-
16
- beforeEach(() => {
17
- request.mockReset();
18
- request.mockResolvedValue({
19
- installId: '550e8400-e29b-41d4-a716-446655440000',
20
- shareBehavior: false,
21
- shareConversation: false,
22
- shareProfile: false,
23
- profile: { displayName: '', email: '', company: '', note: '' },
24
- stats: { events: 2, turns: 1, profile: 0, pending: 3 },
25
- });
26
- });
27
-
28
- afterEach(cleanup);
29
-
30
- describe('InsightsConsentBanner', () => {
31
- it('renders a simple upload-or-cancel first-run banner', async () => {
32
- render(<InsightsConsentBanner />);
33
- const banner = await screen.findByTestId('insights-consent-banner');
34
- expect(banner.className).toContain('bg-agent-muted');
35
- // 3.1 起品牌由产品注入;测试环境无注入 = shell 中性默认。
36
- expect(screen.getByText('帮助改进Steerable Shell')).toBeTruthy();
37
- expect(screen.getByText(/同意上传数据到服务器/)).toBeTruthy();
38
- expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
39
- expect(screen.getByRole('button', { name: '保存' })).toBeTruthy();
40
- expect(screen.getByRole('button', { name: '取消' })).toBeTruthy();
41
- });
42
-
43
- it('save agrees to upload; cancel keeps data local', async () => {
44
- render(<InsightsConsentBanner />);
45
- await screen.findByTestId('insights-consent-banner');
46
- fireEvent.click(screen.getByRole('button', { name: '保存' }));
47
- await waitFor(() => {
48
- expect(request).toHaveBeenCalledWith(
49
- expect.objectContaining({
50
- method: 'POST',
51
- path: '/api/v2/local-settings/insights',
52
- body: expect.objectContaining({
53
- shareBehavior: true,
54
- shareConversation: true,
55
- shareProfile: true,
56
- markPrompted: true,
57
- }),
58
- }),
59
- );
60
- });
61
- });
62
-
63
- it('cancel declines upload and still dismisses the prompt', async () => {
64
- render(<InsightsConsentBanner />);
65
- await screen.findByTestId('insights-consent-banner');
66
- fireEvent.click(screen.getByRole('button', { name: '取消' }));
67
- await waitFor(() => {
68
- expect(request).toHaveBeenCalledWith(
69
- expect.objectContaining({
70
- method: 'POST',
71
- path: '/api/v2/local-settings/insights',
72
- body: expect.objectContaining({
73
- shareBehavior: false,
74
- shareConversation: false,
75
- shareProfile: false,
76
- markPrompted: true,
77
- }),
78
- }),
79
- );
80
- });
81
- });
82
-
83
- it('does not open after the prompt has been answered', async () => {
84
- request.mockResolvedValueOnce({
85
- installId: '550e8400-e29b-41d4-a716-446655440000',
86
- shareBehavior: false,
87
- shareConversation: false,
88
- shareProfile: false,
89
- promptedAt: '2026-09-12T00:00:00.000Z',
90
- profile: { displayName: '', email: '', company: '', note: '' },
91
- });
92
- render(<InsightsConsentBanner />);
93
- await waitFor(() => {
94
- expect(request).toHaveBeenCalledWith(
95
- expect.objectContaining({ method: 'GET', path: '/api/v2/local-settings/insights' }),
96
- );
97
- });
98
- expect(screen.queryByTestId('insights-consent-banner')).toBeNull();
99
- });
100
- });
101
-
102
- describe('InsightsSettingsPanel', () => {
103
- it('shows separate toggles for behavior, conversation, and profile', async () => {
104
- render(<InsightsSettingsPanel />);
105
- expect(await screen.findByText(/帮助改进产品(行为 \/ 对话 \/ 用户信息 分开确认)/)).toBeTruthy();
106
- expect(screen.getAllByText(/自动上传/).length).toBe(3);
107
- expect(screen.getAllByRole('checkbox')).toHaveLength(3);
108
- expect(screen.getByText(/导出文件发给开发者/)).toBeTruthy();
109
- expect(screen.getByText(/现在上传本地记录/)).toBeTruthy();
110
- });
111
-
112
- it('saves the three flags independently', async () => {
113
- render(<InsightsSettingsPanel />);
114
- await screen.findByText(/本机已记/);
115
- const boxes = screen.getAllByRole('checkbox');
116
- fireEvent.click(boxes[0]);
117
- fireEvent.click(screen.getByRole('button', { name: '保存' }));
118
- await waitFor(() => {
119
- expect(request).toHaveBeenCalledWith(
120
- expect.objectContaining({
121
- method: 'POST',
122
- path: '/api/v2/local-settings/insights',
123
- body: expect.objectContaining({
124
- shareBehavior: true,
125
- shareConversation: false,
126
- shareProfile: false,
127
- }),
128
- }),
129
- );
130
- });
131
- });
132
- });
@@ -1,461 +0,0 @@
1
- /**
2
- * LlmSettingsPanel — 本地模型设置面板的渲染与交互:
3
- * - 非桌面环境提示「需要在桌面客户端中打开」,不发起任何配置请求;
4
- * - 挂载自取配置:加载态 → 表单回填(服务商 / URL / Key / 模型 / 预算 / 超时);
5
- * - 切换服务商自动填 URL 与默认模型;ollama 隐藏 API Key 区;
6
- * - 测试凭证 / 刷新模型目录直连网关(refresh 绕过缓存);
7
- * - compat 旗标三态(自动/开/关、枚举、列表)只下发非自动覆盖;
8
- * - 保存走 local-api setLlmSettings:成功回调 onSaved、失败展示错误;
9
- * showFooterSave=false 时经 ref.save() 触发同样的保存。
10
- * 厂商参数预制的完整交互(钉死下拉、命中预览、温度联动)由
11
- * components/LocalLlmSettingsModal.test.tsx 覆盖,这里只验面板级接线。
12
- */
13
- import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
14
- import { createRef } from 'react';
15
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
- import type {
17
- CompatFlagDescriptor,
18
- LlmSettings,
19
- ProviderPresetDescriptor,
20
- } from '@/lib/local-api';
21
- import type { LlmSettingsPanelHandle } from './LlmSettingsPanel';
22
-
23
- const getLlmSettings = vi.fn();
24
- const setLlmSettings = vi.fn();
25
- const getCompatFlags = vi.fn();
26
- const getProviderPresets = vi.fn();
27
- const resolveProviderPreset = vi.fn();
28
- const getCatalogProviders = vi.fn();
29
- const getLlmModels = vi.fn();
30
- const electronState = { active: true };
31
-
32
- vi.mock('@/lib/electron-bridge', () => ({
33
- isElectron: () => electronState.active,
34
- getElectronBridge: () => undefined,
35
- }));
36
- vi.mock('@/lib/local-api', () => ({
37
- getLlmSettings: () => getLlmSettings(),
38
- setLlmSettings: (s: LlmSettings) => setLlmSettings(s),
39
- getCompatFlags: () => getCompatFlags(),
40
- getProviderPresets: () => getProviderPresets(),
41
- resolveProviderPreset: (baseUrl?: string, model?: string) =>
42
- resolveProviderPreset(baseUrl, model),
43
- getCatalogProviders: () => getCatalogProviders(),
44
- getLlmModels: (draft?: {
45
- baseUrl?: string;
46
- apiKey?: string;
47
- provider?: string;
48
- refresh?: boolean;
49
- }) => getLlmModels(draft),
50
- }));
51
-
52
- const { LlmSettingsPanel } = await import('./LlmSettingsPanel');
53
-
54
- // 通用占位服务商(框架目录由 sidecar 下发,这里用虚构条目避免产品耦合)。
55
- const ACME = {
56
- id: 'acme',
57
- apiBaseUrl: 'https://api.acme.example/v1',
58
- envVars: ['ACME_API_KEY'],
59
- wireKind: 'openai_compat',
60
- models: ['acme-chat', 'acme-pro'],
61
- };
62
- const MESSAGES_API = {
63
- id: 'messages-api',
64
- apiBaseUrl: 'https://messages.example',
65
- envVars: ['MESSAGES_API_KEY'],
66
- wireKind: 'anthropic',
67
- models: ['msg-large', 'msg-small'],
68
- };
69
-
70
- const BASE_SETTINGS: LlmSettings = {
71
- provider: 'openai-compat',
72
- vendorId: 'acme',
73
- model: 'acme-chat',
74
- baseUrl: 'https://api.acme.example/v1',
75
- apiKey: 'sk-test',
76
- maxTotalTokens: 60000,
77
- };
78
-
79
- const COMPAT_FLAGS: CompatFlagDescriptor[] = [
80
- {
81
- key: 'supportsTemperature',
82
- field: 'supports_temperature',
83
- kind: 'bool',
84
- default: null,
85
- description: '网关是否接受 temperature 字段。',
86
- },
87
- {
88
- key: 'maxTokensField',
89
- field: 'max_tokens_field',
90
- kind: 'enum:max_tokens,max_completion_tokens',
91
- default: null,
92
- description: '最大 token 字段名。',
93
- },
94
- {
95
- key: 'reasoningDeltaFields',
96
- field: 'reasoning_delta_fields',
97
- kind: 'string-list',
98
- default: null,
99
- description: '推理增量可能出现的字段。',
100
- },
101
- ];
102
-
103
- const ACME_PRESET: ProviderPresetDescriptor = {
104
- host: 'api.acme.example',
105
- modelPrefix: 'acme',
106
- temperature: 0.2,
107
- topP: null,
108
- maxTokens: null,
109
- reasoningEffort: null,
110
- extraBody: null,
111
- };
112
-
113
- beforeEach(() => {
114
- vi.clearAllMocks();
115
- electronState.active = true;
116
- getCompatFlags.mockResolvedValue({ flags: COMPAT_FLAGS });
117
- getProviderPresets.mockResolvedValue({ presets: [ACME_PRESET] });
118
- resolveProviderPreset.mockResolvedValue({ preset: null });
119
- getCatalogProviders.mockResolvedValue({ providers: [ACME, MESSAGES_API] });
120
- getLlmModels.mockResolvedValue({ models: [], catalogStatus: 'offline' });
121
- setLlmSettings.mockImplementation(async (s: LlmSettings) => s);
122
- });
123
-
124
- afterEach(cleanup);
125
-
126
- interface PanelProps {
127
- onSaved?: (settings: LlmSettings) => void;
128
- onSaveUiChange?: (ui: { saving: boolean; savedOk: boolean; loading: boolean }) => void;
129
- showFooterSave?: boolean;
130
- ref?: React.Ref<LlmSettingsPanelHandle>;
131
- }
132
-
133
- /** 渲染并等 reload 完成(服务商下拉出现即数据就绪)。 */
134
- async function renderPanel(settings: Partial<LlmSettings> = {}, props: PanelProps = {}) {
135
- getLlmSettings.mockResolvedValue({ ...BASE_SETTINGS, ...settings });
136
- const utils = render(<LlmSettingsPanel {...props} />);
137
- await screen.findByTestId('llm-vendor-select');
138
- return utils;
139
- }
140
-
141
- /** 等挂载后的模型目录防抖拉取(400ms)落定,避免干扰后续调用断言。 */
142
- async function settleCatalog() {
143
- await screen.findByText(/网关目录不可用/, undefined, { timeout: 3000 });
144
- }
145
-
146
- describe('LlmSettingsPanel — 环境与加载', () => {
147
- it('非桌面环境提示需要在客户端打开,且不请求配置', async () => {
148
- electronState.active = false;
149
- render(<LlmSettingsPanel />);
150
- expect(await screen.findByText('需要在桌面客户端中打开')).toBeTruthy();
151
- expect(getLlmSettings).not.toHaveBeenCalled();
152
- });
153
-
154
- it('加载期间显示加载态并禁用保存,完成后渲染表单', async () => {
155
- let resolveSettings: (s: LlmSettings) => void = () => {};
156
- getLlmSettings.mockReturnValue(
157
- new Promise((resolve) => {
158
- resolveSettings = resolve;
159
- }),
160
- );
161
- render(<LlmSettingsPanel />);
162
- expect(await screen.findByText('加载当前配置...')).toBeTruthy();
163
- expect((screen.getByTestId('llm-settings-save') as HTMLButtonElement).disabled).toBe(true);
164
-
165
- resolveSettings({ ...BASE_SETTINGS });
166
- expect(await screen.findByTestId('llm-vendor-select')).toBeTruthy();
167
- expect((screen.getByTestId('llm-settings-save') as HTMLButtonElement).disabled).toBe(false);
168
- });
169
-
170
- it('读取配置失败时展示错误', async () => {
171
- getLlmSettings.mockRejectedValue(new Error('存储损坏'));
172
- render(<LlmSettingsPanel />);
173
- expect(await screen.findByText('存储损坏')).toBeTruthy();
174
- });
175
-
176
- it('sidecar 未就绪时表单仍可用,旗标区与预制下拉给出提示', async () => {
177
- getCompatFlags.mockRejectedValue(new Error('sidecar down'));
178
- getProviderPresets.mockRejectedValue(new Error('sidecar down'));
179
- getCatalogProviders.mockRejectedValue(new Error('sidecar down'));
180
- await renderPanel();
181
- expect(screen.getByText(/旗标词汇表由 sidecar 提供/)).toBeTruthy();
182
-
183
- fireEvent.click(document.querySelector('[data-preset-mode="pinned"]')!);
184
- expect(screen.getByText(/预制注册表由 sidecar 提供/)).toBeTruthy();
185
-
186
- fireEvent.click(screen.getByTestId('llm-settings-save'));
187
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
188
- });
189
- });
190
-
191
- describe('LlmSettingsPanel — 表单回填与渲染', () => {
192
- it('回填已保存的服务商、URL、Key、模型、预算与超时', async () => {
193
- await renderPanel({ maxTotalTokens: 8000, execTimeoutSeconds: 120 });
194
- expect((screen.getByTestId('llm-vendor-select') as HTMLSelectElement).value).toBe('acme');
195
- expect(screen.getByDisplayValue('https://api.acme.example/v1')).toBeTruthy();
196
- expect(screen.getByDisplayValue('sk-test')).toBeTruthy();
197
- expect((screen.getByTestId('llm-model-input') as HTMLInputElement).value).toBe('acme-chat');
198
- expect(screen.getByDisplayValue('8000')).toBeTruthy();
199
- expect(screen.getByDisplayValue('120')).toBeTruthy();
200
- });
201
-
202
- it('ollama 服务商不渲染 API Key 区', async () => {
203
- await renderPanel({
204
- provider: 'ollama',
205
- vendorId: 'ollama',
206
- model: 'llama3.1:8b',
207
- baseUrl: 'http://127.0.0.1:11434',
208
- apiKey: '',
209
- });
210
- expect(screen.queryByText('API Key')).toBeNull();
211
- expect(screen.queryByTestId('llm-key-test')).toBeNull();
212
- });
213
-
214
- it('未填写 API Key 时显示创建密钥的引导提示', async () => {
215
- await renderPanel({ apiKey: '' });
216
- expect(screen.getByText(/尚未配置 API Key/)).toBeTruthy();
217
- });
218
-
219
- it('切换服务商后自动填入默认 URL 与目录首个模型,并隐藏 OpenAI 专属区块', async () => {
220
- await renderPanel();
221
- fireEvent.change(screen.getByTestId('llm-vendor-select'), {
222
- target: { value: 'messages-api' },
223
- });
224
- const url = screen.getByPlaceholderText('https://messages.example') as HTMLInputElement;
225
- expect(url.value).toBe('https://messages.example');
226
- expect((screen.getByTestId('llm-model-input') as HTMLInputElement).value).toBe('msg-large');
227
- // anthropic 原生协议:不显示 compat 旗标区与厂商预制区
228
- expect(screen.queryByText('高级兼容旗标(可选)')).toBeNull();
229
- expect(screen.queryByText('厂商参数预制')).toBeNull();
230
- });
231
- });
232
-
233
- describe('LlmSettingsPanel — 网关目录与凭证', () => {
234
- it('「测试」用当前 URL 与 Key 拉取网关目录并回报模型数', async () => {
235
- getLlmModels.mockResolvedValue({
236
- models: [{ id: 'acme-chat' }, { id: 'acme-pro' }],
237
- catalogStatus: 'live',
238
- });
239
- await renderPanel();
240
- await screen.findByText(/已从网关拉取 2 个模型/, undefined, { timeout: 3000 });
241
-
242
- getLlmModels.mockClear();
243
- fireEvent.click(screen.getByTestId('llm-key-test'));
244
- await screen.findByText(/凭证可用,网关返回 2 个模型/);
245
- expect(getLlmModels.mock.calls.at(-1)?.[0]).toMatchObject({
246
- baseUrl: 'https://api.acme.example/v1',
247
- apiKey: 'sk-test',
248
- refresh: true,
249
- });
250
- });
251
-
252
- it('凭证验证失败时展示网关返回的错误', async () => {
253
- await renderPanel();
254
- await settleCatalog();
255
- getLlmModels.mockResolvedValue({
256
- models: [],
257
- catalogStatus: 'offline',
258
- error: '401 unauthorized',
259
- });
260
- fireEvent.click(screen.getByTestId('llm-key-test'));
261
- expect(await screen.findByText(/验证失败:401 unauthorized/)).toBeTruthy();
262
- });
263
-
264
- it('刷新按钮绕过缓存重新拉取模型目录', async () => {
265
- getLlmModels.mockResolvedValue({
266
- models: [{ id: 'acme-chat' }],
267
- catalogStatus: 'live',
268
- });
269
- await renderPanel();
270
- await screen.findByText(/已从网关拉取 1 个模型/, undefined, { timeout: 3000 });
271
-
272
- getLlmModels.mockClear();
273
- getLlmModels.mockResolvedValue({
274
- models: [{ id: 'acme-chat' }, { id: 'acme-pro' }],
275
- catalogStatus: 'live',
276
- });
277
- fireEvent.click(screen.getByTestId('llm-model-refresh'));
278
- await screen.findByText(/已从网关拉取 2 个模型/);
279
- expect(getLlmModels.mock.calls.at(-1)?.[0]).toMatchObject({ refresh: true });
280
- });
281
-
282
- it('修改 Base URL 后按新地址防抖拉取模型目录', async () => {
283
- await renderPanel();
284
- await settleCatalog();
285
- getLlmModels.mockClear();
286
- fireEvent.change(screen.getByDisplayValue('https://api.acme.example/v1'), {
287
- target: { value: 'https://relay.example/v1' },
288
- });
289
- await waitFor(() => expect(getLlmModels).toHaveBeenCalled(), { timeout: 3000 });
290
- expect(getLlmModels.mock.calls.at(-1)?.[0]).toMatchObject({
291
- baseUrl: 'https://relay.example/v1',
292
- });
293
- });
294
- });
295
-
296
- describe('LlmSettingsPanel — compat 旗标', () => {
297
- it('按词汇表渲染 bool / 枚举 / 列表三种旗标,保存时只下发非自动覆盖', async () => {
298
- await renderPanel();
299
- const boolRow = document.querySelector('[data-compat-flag="supportsTemperature"]')!;
300
- fireEvent.click(within(boolRow as HTMLElement).getByText('开'));
301
-
302
- const enumRow = document.querySelector('[data-compat-flag="maxTokensField"]')!;
303
- fireEvent.change(within(enumRow as HTMLElement).getByRole('combobox'), {
304
- target: { value: 'max_completion_tokens' },
305
- });
306
-
307
- const listRow = document.querySelector('[data-compat-flag="reasoningDeltaFields"]')!;
308
- fireEvent.change(within(listRow as HTMLElement).getByRole('textbox'), {
309
- target: { value: 'reasoning, delta' },
310
- });
311
-
312
- fireEvent.click(screen.getByTestId('llm-settings-save'));
313
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
314
- expect(setLlmSettings.mock.calls[0][0].compat).toEqual({
315
- supportsTemperature: true,
316
- maxTokensField: 'max_completion_tokens',
317
- reasoningDeltaFields: ['reasoning', 'delta'],
318
- });
319
- });
320
-
321
- it('旗标全部自动时保存不写 compat', async () => {
322
- await renderPanel();
323
- fireEvent.click(screen.getByTestId('llm-settings-save'));
324
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
325
- expect(setLlmSettings.mock.calls[0][0].compat).toBeUndefined();
326
- });
327
-
328
- it('已保存的覆盖回填为对应档位', async () => {
329
- await renderPanel({
330
- compat: { supportsTemperature: false, maxTokensField: 'max_completion_tokens' },
331
- });
332
- const boolRow = document.querySelector('[data-compat-flag="supportsTemperature"]')!;
333
- const offButton = within(boolRow as HTMLElement).getByText('关');
334
- expect(offButton.className).toContain('bg-agent-foreground');
335
- const enumRow = document.querySelector('[data-compat-flag="maxTokensField"]')!;
336
- expect(
337
- (within(enumRow as HTMLElement).getByRole('combobox') as HTMLSelectElement).value,
338
- ).toBe('max_completion_tokens');
339
- });
340
- });
341
-
342
- describe('LlmSettingsPanel — 保存流程', () => {
343
- it('保存成功:下发完整载荷、回调 onSaved 并上报保存态', async () => {
344
- const onSaved = vi.fn();
345
- const onSaveUiChange = vi.fn();
346
- await renderPanel({}, { onSaved, onSaveUiChange });
347
- fireEvent.click(screen.getByTestId('llm-settings-save'));
348
- await waitFor(() => expect(onSaved).toHaveBeenCalled());
349
- expect(onSaved.mock.calls[0][0]).toMatchObject({
350
- provider: 'openai-compat',
351
- vendorId: 'acme',
352
- model: 'acme-chat',
353
- baseUrl: 'https://api.acme.example/v1',
354
- apiKey: 'sk-test',
355
- });
356
- // 加载期间上报过 loading:true;保存完成后 savedOk:true。
357
- expect(onSaveUiChange.mock.calls.some(([ui]) => ui.loading === true)).toBe(true);
358
- await waitFor(() => {
359
- expect(onSaveUiChange.mock.calls.at(-1)?.[0]).toMatchObject({
360
- saving: false,
361
- savedOk: true,
362
- loading: false,
363
- });
364
- });
365
- });
366
-
367
- it('保存失败时展示错误且不回调 onSaved', async () => {
368
- setLlmSettings.mockRejectedValue(new Error('磁盘只读'));
369
- const onSaved = vi.fn();
370
- await renderPanel({}, { onSaved });
371
- fireEvent.click(screen.getByTestId('llm-settings-save'));
372
- expect(await screen.findByText('磁盘只读')).toBeTruthy();
373
- expect(onSaved).not.toHaveBeenCalled();
374
- });
375
-
376
- it('保存时 trim URL 与 Key,空白 Key 不下发', async () => {
377
- await renderPanel();
378
- fireEvent.change(screen.getByDisplayValue('https://api.acme.example/v1'), {
379
- target: { value: ' https://api.acme.example/v2 ' },
380
- });
381
- fireEvent.change(screen.getByDisplayValue('sk-test'), { target: { value: ' ' } });
382
- fireEvent.click(screen.getByTestId('llm-settings-save'));
383
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
384
- expect(setLlmSettings.mock.calls[0][0].baseUrl).toBe('https://api.acme.example/v2');
385
- expect(setLlmSettings.mock.calls[0][0].apiKey).toBeUndefined();
386
- });
387
-
388
- it('本地命令超时留空或非法时不下发,填正整数时随保存下发', async () => {
389
- await renderPanel();
390
- const input = screen.getByPlaceholderText('留空 = 默认(后台 30s / 终端 60s)');
391
- fireEvent.change(input, { target: { value: 'abc' } });
392
- fireEvent.click(screen.getByTestId('llm-settings-save'));
393
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
394
- expect(setLlmSettings.mock.calls[0][0].execTimeoutSeconds).toBeUndefined();
395
-
396
- setLlmSettings.mockClear();
397
- fireEvent.change(input, { target: { value: '90' } });
398
- fireEvent.click(screen.getByTestId('llm-settings-save'));
399
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
400
- expect(setLlmSettings.mock.calls[0][0].execTimeoutSeconds).toBe(90);
401
- });
402
-
403
- it('Token 预算与手填模型 id 随保存下发', async () => {
404
- await renderPanel();
405
- fireEvent.change(screen.getByDisplayValue('60000'), { target: { value: '12000' } });
406
- fireEvent.change(screen.getByTestId('llm-model-input'), { target: { value: 'acme-pro' } });
407
- fireEvent.click(screen.getByTestId('llm-settings-save'));
408
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
409
- expect(setLlmSettings.mock.calls[0][0].maxTotalTokens).toBe(12000);
410
- expect(setLlmSettings.mock.calls[0][0].model).toBe('acme-pro');
411
- });
412
-
413
- it('showFooterSave=false 时不渲染底部按钮,可经 ref.save() 触发保存', async () => {
414
- const ref = createRef<LlmSettingsPanelHandle>();
415
- await renderPanel({}, { showFooterSave: false, ref });
416
- expect(screen.queryByTestId('llm-settings-save')).toBeNull();
417
- await act(async () => {
418
- await ref.current!.save();
419
- });
420
- expect(setLlmSettings).toHaveBeenCalled();
421
- });
422
- });
423
-
424
- describe('LlmSettingsPanel — 温度与预制(面板级接线)', () => {
425
- it('Temperature 缺省自动(无滑杆、保存不下发),切手动后滑杆值随保存下发', async () => {
426
- await renderPanel();
427
- expect(document.querySelector('input[type="range"]')).toBeNull();
428
- fireEvent.click(screen.getByTestId('llm-settings-save'));
429
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
430
- expect(setLlmSettings.mock.calls[0][0].temperature).toBeUndefined();
431
-
432
- setLlmSettings.mockClear();
433
- fireEvent.click(document.querySelector('[data-temperature-mode="manual"]')!);
434
- const slider = document.querySelector('input[type="range"]') as HTMLInputElement;
435
- expect(slider).toBeTruthy();
436
- fireEvent.change(slider, { target: { value: '0.5' } });
437
- fireEvent.click(screen.getByTestId('llm-settings-save'));
438
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
439
- expect(setLlmSettings.mock.calls[0][0].temperature).toBe(0.5);
440
- });
441
-
442
- it('「自动」档命中预制时预览生效参数,保存不写 presets', async () => {
443
- resolveProviderPreset.mockResolvedValue({ preset: { temperature: 0.2 } });
444
- await renderPanel();
445
- expect(
446
- await screen.findByText(/命中预制:temperature 0.2/, undefined, { timeout: 3000 }),
447
- ).toBeTruthy();
448
- fireEvent.click(screen.getByTestId('llm-settings-save'));
449
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
450
- expect(setLlmSettings.mock.calls[0][0].presets).toBeUndefined();
451
- });
452
-
453
- it('预制「关闭」保存 {enabled:false}', async () => {
454
- await renderPanel();
455
- fireEvent.click(document.querySelector('[data-preset-mode="off"]')!);
456
- expect(screen.getByText('已关闭:不下发厂商预制参数。')).toBeTruthy();
457
- fireEvent.click(screen.getByTestId('llm-settings-save'));
458
- await waitFor(() => expect(setLlmSettings).toHaveBeenCalled());
459
- expect(setLlmSettings.mock.calls[0][0].presets).toEqual({ enabled: false });
460
- });
461
- });
@@ -1,88 +0,0 @@
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
- });