@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,69 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { LuX } from 'react-icons/lu';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 右侧 dock(终端 / 后台推理)的统一头部 chrome。
|
|
6
|
+
*
|
|
7
|
+
* 两个面板的正文配色不同——终端固定深色,过程栏跟主题——但头部保持同一
|
|
8
|
+
* 套几何与字号,切换视图时标题栏不跳动。
|
|
9
|
+
*/
|
|
10
|
+
export function DockPanelHeader({
|
|
11
|
+
title,
|
|
12
|
+
actions,
|
|
13
|
+
onClose,
|
|
14
|
+
closeLabel,
|
|
15
|
+
}: {
|
|
16
|
+
/** 左侧标题,超长截断。 */
|
|
17
|
+
title: string;
|
|
18
|
+
/** 右侧动作区(视图切换按钮等),排在关闭按钮之前。 */
|
|
19
|
+
actions?: ReactNode;
|
|
20
|
+
/** 缺省时不渲染关闭按钮。 */
|
|
21
|
+
onClose?: () => void;
|
|
22
|
+
/** 关闭按钮的 title / aria-label。 */
|
|
23
|
+
closeLabel?: string;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex items-center gap-2 border-b border-agent-border bg-agent-canvas px-3 py-1.5">
|
|
27
|
+
<span className="min-w-0 flex-1 truncate text-[11px] text-agent-muted-foreground">
|
|
28
|
+
{title}
|
|
29
|
+
</span>
|
|
30
|
+
{actions}
|
|
31
|
+
{onClose && (
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={onClose}
|
|
35
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
36
|
+
title={closeLabel}
|
|
37
|
+
aria-label={closeLabel}
|
|
38
|
+
>
|
|
39
|
+
<LuX className="h-3 w-3" />
|
|
40
|
+
</button>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** dock 头部的视图切换按钮(图标 + 文字),与关闭按钮同高。 */
|
|
47
|
+
export function DockHeaderButton({
|
|
48
|
+
icon,
|
|
49
|
+
label,
|
|
50
|
+
title,
|
|
51
|
+
onClick,
|
|
52
|
+
}: {
|
|
53
|
+
icon: ReactNode;
|
|
54
|
+
label: string;
|
|
55
|
+
title?: string;
|
|
56
|
+
onClick: () => void;
|
|
57
|
+
}) {
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={onClick}
|
|
62
|
+
className="flex h-5 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
63
|
+
title={title ?? label}
|
|
64
|
+
>
|
|
65
|
+
{icon}
|
|
66
|
+
{label}
|
|
67
|
+
</button>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { LuX } from 'react-icons/lu';
|
|
4
|
+
import type { LlmSettings } from '@/lib/local-api';
|
|
5
|
+
import { LlmSettingsPanel } from '@/components/settings/LlmSettingsPanel';
|
|
6
|
+
|
|
7
|
+
interface LocalLlmSettingsModalProps {
|
|
8
|
+
open: boolean;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
onSaved?: (settings: LlmSettings) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 输入区齿轮的快捷入口:弹窗只承载本地模型表单。
|
|
15
|
+
* 侧栏「设置」走 `/settings` 页(模型 + 洞察 / 遥测 / 用量 / 搜索 / 安全)。
|
|
16
|
+
*/
|
|
17
|
+
export function LocalLlmSettingsModal({ open, onClose, onSaved }: LocalLlmSettingsModalProps) {
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!open) return;
|
|
20
|
+
const onKey = (e: KeyboardEvent) => {
|
|
21
|
+
if (e.key === 'Escape') onClose();
|
|
22
|
+
};
|
|
23
|
+
window.addEventListener('keydown', onKey);
|
|
24
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
25
|
+
}, [open, onClose]);
|
|
26
|
+
|
|
27
|
+
if (!open) return null;
|
|
28
|
+
if (typeof document === 'undefined') return null;
|
|
29
|
+
|
|
30
|
+
return createPortal(
|
|
31
|
+
<div
|
|
32
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
33
|
+
onMouseDown={(e) => {
|
|
34
|
+
// 只认点在遮罩上的按下。面板内拖选/下拉选项后在阴影上弹起,
|
|
35
|
+
// 合成 click 会落到遮罩,不能当关闭。
|
|
36
|
+
if (e.target === e.currentTarget) onClose();
|
|
37
|
+
}}
|
|
38
|
+
role="dialog"
|
|
39
|
+
aria-modal="true"
|
|
40
|
+
data-testid="llm-settings-dialog"
|
|
41
|
+
>
|
|
42
|
+
<div
|
|
43
|
+
className="flex h-[560px] max-h-[90vh] w-[540px] max-w-[92vw] flex-col overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas shadow-2xl animate-fade-in"
|
|
44
|
+
>
|
|
45
|
+
<div className="flex h-12 flex-shrink-0 items-center justify-between border-b border-agent-border px-5">
|
|
46
|
+
<h2 className="text-sm font-semibold text-agent-foreground">本地模型设置</h2>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
onClick={onClose}
|
|
50
|
+
className="rounded-full p-1 text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground"
|
|
51
|
+
aria-label="关闭"
|
|
52
|
+
>
|
|
53
|
+
<LuX className="h-4 w-4" />
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="flex-1 overflow-y-auto p-5">
|
|
57
|
+
<LlmSettingsPanel onSaved={onSaved} />
|
|
58
|
+
</div>
|
|
59
|
+
<div className="flex h-12 flex-shrink-0 items-center justify-end border-t border-agent-border px-5 bg-agent-muted/10">
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={onClose}
|
|
63
|
+
className="h-8 rounded-full px-4 text-xs font-medium text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground"
|
|
64
|
+
>
|
|
65
|
+
关闭
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>,
|
|
70
|
+
document.body,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Suspense, lazy, useEffect, useState } from 'react';
|
|
2
|
+
import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
3
|
+
|
|
4
|
+
// 把 xterm 那一坨 (~300KB) 拆到独立 chunk —— 用户没打开终端就不下载。
|
|
5
|
+
// 跟原 Next.js 版本里 `dynamic(() => import('./TerminalView'), {ssr:false})`
|
|
6
|
+
// 等价。
|
|
7
|
+
const TerminalView = lazy(() =>
|
|
8
|
+
import('@/components/TerminalView').then((m) => ({ default: m.TerminalView })),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 内嵌终端面板 —— 挂在 AgentLayout 右侧内容区,与 chat 两栏并排
|
|
13
|
+
* (Codex 式一体化终端),由 sidebar 终端按钮 / Cmd+T 切换显隐。
|
|
14
|
+
*
|
|
15
|
+
* PTY 会话由 main 进程的 `TerminalManager` 单例持有:关闭面板只是卸载
|
|
16
|
+
* xterm 视图,不杀 shell;重新打开时通过 `terminal:ensure` 的 replay
|
|
17
|
+
* buffer 补回面板关闭期间的输出。
|
|
18
|
+
*
|
|
19
|
+
* Electron 检测保留给浏览器预览 / dev 模式(没有 `window.electron` 就
|
|
20
|
+
* 连不到本地 PTY)。延迟到首个 effect 再渲染,避免检测闪烁。
|
|
21
|
+
*/
|
|
22
|
+
export function TerminalPanel({
|
|
23
|
+
onClose,
|
|
24
|
+
onShowTaskProcess,
|
|
25
|
+
taskProcessTitle,
|
|
26
|
+
}: {
|
|
27
|
+
onClose?: () => void;
|
|
28
|
+
/** 头部「后台任务」按钮:切回本次会话看过的后台推理过程。 */
|
|
29
|
+
onShowTaskProcess?: () => void;
|
|
30
|
+
taskProcessTitle?: string;
|
|
31
|
+
}) {
|
|
32
|
+
const [hasElectron, setHasElectron] = useState<boolean | null>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setHasElectron(isElectron() && !!getElectronBridge()?.terminal);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
if (hasElectron === null) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (!hasElectron) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex h-full w-full items-center justify-center bg-black p-6 text-center font-mono text-sm text-white">
|
|
44
|
+
<div>
|
|
45
|
+
<p className="mb-2">⚠️ 此面板需要桌面客户端或 BS server 连接。</p>
|
|
46
|
+
<p className="text-white/60">
|
|
47
|
+
当前页面没有可用的宿主 bridge,无法连接到本地 PTY。
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return (
|
|
54
|
+
<Suspense
|
|
55
|
+
fallback={
|
|
56
|
+
<div className="flex h-full w-full items-center justify-center bg-[#0b0b0c] font-mono text-xs text-white/60">
|
|
57
|
+
Loading terminal…
|
|
58
|
+
</div>
|
|
59
|
+
}
|
|
60
|
+
>
|
|
61
|
+
<TerminalView
|
|
62
|
+
onClose={onClose}
|
|
63
|
+
onShowTaskProcess={onShowTaskProcess}
|
|
64
|
+
taskProcessTitle={taskProcessTitle}
|
|
65
|
+
/>
|
|
66
|
+
</Suspense>
|
|
67
|
+
);
|
|
68
|
+
}
|