@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,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local chat transport — bridges Electron IPC SSE -> `@steerable/agent-ui`'s
|
|
3
|
+
* `ChatStreamTransport` contract.
|
|
4
|
+
*
|
|
5
|
+
* Pipeline:
|
|
6
|
+
*
|
|
7
|
+
* user types in ChatPanel
|
|
8
|
+
* -> useChatStream.sendUserMessage()
|
|
9
|
+
* -> transport.stream(input, onEvent)
|
|
10
|
+
* -> window.electron.localBackend.startStream({ POST /api/v2/chats/:id/run })
|
|
11
|
+
* -> main process forwards each raw SSE chunk via webContents.send
|
|
12
|
+
* -> preload re-emits as `{ type: 'data'; chunk: string }`
|
|
13
|
+
* -> we parse the SSE frames with the framework's `SSEParser`, normalise
|
|
14
|
+
* the payload via `bridgeLegacySSE`, and call `onEvent` for each
|
|
15
|
+
* normalised event
|
|
16
|
+
* -> useChatStream's reducer renders messages
|
|
17
|
+
*
|
|
18
|
+
* Local-backend's wire format (see src/local-backend/router.ts handleStream):
|
|
19
|
+
*
|
|
20
|
+
* - Standard content tokens: `data: {"content":"..."}\n\n`
|
|
21
|
+
* - User message echo: `data: {"type":"user_message", "message":{...}}\n\n`
|
|
22
|
+
* - Tool execution summary: `data: {"type":"executed_actions", "actions":[...]}\n\n`
|
|
23
|
+
* - Per-round completion: `data: {"type":"completion", "status":"...", "reason":"..."}\n\n`
|
|
24
|
+
* - Budget exhausted: `data: {"type":"budget_exhausted", "budget":{...}}\n\n`
|
|
25
|
+
* - Persisted message id: `data: {"type":"message_id", "messageId":"..."}\n\n`
|
|
26
|
+
* - Named error event: `event: error\ndata: {"message":"..."}\n\n`
|
|
27
|
+
* - Stream terminator: `data: [DONE]\n\n`
|
|
28
|
+
*
|
|
29
|
+
* Framework `SSEEvent` (packages/agent-protocol/ts/src/generated/SSEEvent.ts)
|
|
30
|
+
* is the canonical normalisation target. The legacy-envelope mapping lives in
|
|
31
|
+
* `@steerable/agent-ui/state`'s `bridgeLegacySSE` — this file used to keep its
|
|
32
|
+
* own copy; wave 1 of the steerable-framework refactor consolidated them.
|
|
33
|
+
*
|
|
34
|
+
* We do still keep one app-specific guard: if any content has already
|
|
35
|
+
* streamed and the backend ends with `budget_exhausted`, we downgrade that
|
|
36
|
+
* event to a generic `agent/round_end` + `done` so the framework hook
|
|
37
|
+
* doesn't blow away the already-rendered text via patch-last-assistant.
|
|
38
|
+
*
|
|
39
|
+
* Open issue (upstream): framework `useChatStream`'s cancel pathway stores
|
|
40
|
+
* the cancel handle only AFTER `await transport.stream(...)` resolves, by
|
|
41
|
+
* which point the stream has already ended. So the framework's Stop button
|
|
42
|
+
* is effectively a no-op today. We still return a cancel function from the
|
|
43
|
+
* resolved promise per the contract; full cancel UX needs an upstream fix.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type {
|
|
47
|
+
ChatStreamTransport,
|
|
48
|
+
ChatStreamSendInput,
|
|
49
|
+
} from '@steerable/agent-ui';
|
|
50
|
+
import {
|
|
51
|
+
SSEParser,
|
|
52
|
+
bridgeLegacySSE,
|
|
53
|
+
parseSSEData,
|
|
54
|
+
} from '@steerable/agent-ui/state';
|
|
55
|
+
import type { SSEEvent } from '@steerable/agent-protocol';
|
|
56
|
+
import { getElectronBridge } from './electron-bridge';
|
|
57
|
+
import { appendDelta, syncTools, type TurnBlock } from '@/components/chat/turn-timeline';
|
|
58
|
+
import type { ExecutedAction } from '@/components/chat/ExecutedActionsCard';
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Local-backend SSE adapter — feeds opaque IPC chunks into the framework's
|
|
62
|
+
// `SSEParser`, then runs every frame through `bridgeLegacySSE` and applies a
|
|
63
|
+
// small app-specific guard around `budget_exhausted` (see file header).
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
class LocalBackendSseAdapter {
|
|
67
|
+
/** True once any content delta has been forwarded for this stream. */
|
|
68
|
+
private hasStreamedContent = false;
|
|
69
|
+
private completed = false;
|
|
70
|
+
private readonly parser: SSEParser;
|
|
71
|
+
private timeline: TurnBlock[] = [];
|
|
72
|
+
|
|
73
|
+
constructor(private readonly onEvent: (event: SSEEvent) => void) {
|
|
74
|
+
this.parser = new SSEParser({
|
|
75
|
+
onFrame: (frame) => {
|
|
76
|
+
if (!frame.data) return;
|
|
77
|
+
const parsed = parseSSEData(frame.data);
|
|
78
|
+
const ev = bridgeLegacySSE(parsed, frame.event);
|
|
79
|
+
if (ev) this.handleNormalised(ev);
|
|
80
|
+
},
|
|
81
|
+
onComplete: () => {
|
|
82
|
+
if (!this.completed) {
|
|
83
|
+
this.completed = true;
|
|
84
|
+
this.onEvent({ type: 'done' });
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
feed(chunk: string): void {
|
|
91
|
+
this.parser.feed(chunk);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
end(): void {
|
|
95
|
+
this.parser.end();
|
|
96
|
+
if (!this.completed) {
|
|
97
|
+
this.completed = true;
|
|
98
|
+
this.onEvent({ type: 'done' });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private handleNormalised(event: SSEEvent): void {
|
|
103
|
+
if (event.type === 'content' && typeof event.content === 'string' && event.content.length > 0) {
|
|
104
|
+
this.hasStreamedContent = true;
|
|
105
|
+
}
|
|
106
|
+
if (event.type === 'budget_exhausted' && this.hasStreamedContent) {
|
|
107
|
+
this.onEvent({
|
|
108
|
+
type: 'agent',
|
|
109
|
+
event: 'budget_exhausted_suppressed',
|
|
110
|
+
payload: { reason: event.message ?? 'budget_exhausted' },
|
|
111
|
+
} as any);
|
|
112
|
+
if (!this.completed) {
|
|
113
|
+
this.completed = true;
|
|
114
|
+
this.onEvent({ type: 'done' });
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const timelineChanged = this.applyToTimeline(event);
|
|
119
|
+
this.onEvent(event);
|
|
120
|
+
if (timelineChanged) {
|
|
121
|
+
this.onEvent({
|
|
122
|
+
type: 'agent',
|
|
123
|
+
event: 'turn_timeline',
|
|
124
|
+
payload: { blocks: this.timeline },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private applyToTimeline(event: SSEEvent): boolean {
|
|
130
|
+
if (event.type === 'content' && typeof event.content === 'string' && event.content.length > 0) {
|
|
131
|
+
this.timeline = appendDelta(this.timeline, 'text', event.content);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (event.type !== 'agent') return false;
|
|
135
|
+
if (event.event === 'reasoning') {
|
|
136
|
+
const delta =
|
|
137
|
+
typeof event.payload?.content === 'string'
|
|
138
|
+
? event.payload.content
|
|
139
|
+
: typeof event.payload?.delta === 'string'
|
|
140
|
+
? event.payload.delta
|
|
141
|
+
: '';
|
|
142
|
+
if (!delta) return false;
|
|
143
|
+
this.timeline = appendDelta(this.timeline, 'reasoning', delta);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (event.event === 'executed_actions') {
|
|
147
|
+
const actions = event.payload?.actions as ExecutedAction[] | undefined;
|
|
148
|
+
if (!Array.isArray(actions)) return false;
|
|
149
|
+
this.timeline = syncTools(this.timeline, actions);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Transport factory — bind to a specific chatId. Each `stream()` invocation
|
|
158
|
+
// starts a fresh SSE pipe against /api/v2/chats/:id/run.
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
export interface ElectronChatTransport extends ChatStreamTransport {
|
|
162
|
+
/**
|
|
163
|
+
* 立即取消当前进行中的流(如果有)。
|
|
164
|
+
*
|
|
165
|
+
* 框架的 `useChatStream` 只在 `transport.stream()` resolve 之后才保存
|
|
166
|
+
* cancel 句柄——那时流已经结束,Stop 按钮等于空操作。这个方法绕过该缺陷:
|
|
167
|
+
* transport 自己记住 in-flight streamId,UI 层(Stop 按钮 / 组件卸载)
|
|
168
|
+
* 直接调它把主进程里的 agent 循环真正停下来。
|
|
169
|
+
*/
|
|
170
|
+
cancelActive: () => void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createElectronChatTransport(chatId: string): ElectronChatTransport {
|
|
174
|
+
// 当前 in-flight 流的 id。startStream resolve 后写入,流结束(end/error)
|
|
175
|
+
// 或被取消后清空。同一 transport 一次只会有一条流(useChatStream 在
|
|
176
|
+
// isStreaming 时拒绝重入)。
|
|
177
|
+
const active: { streamId: string | null } = { streamId: null };
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
cancelActive: () => {
|
|
181
|
+
const bridge = getElectronBridge();
|
|
182
|
+
if (active.streamId) {
|
|
183
|
+
bridge?.localBackend.cancelStream(active.streamId);
|
|
184
|
+
active.streamId = null;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// 切走再切回后:当前 mount 没有原 streamId(它属于上一个已卸载的
|
|
188
|
+
// mount),改用 chatId 取消后端仍在跑的回合。fire-and-forget。
|
|
189
|
+
if (bridge) {
|
|
190
|
+
void bridge.localBackend
|
|
191
|
+
.request({
|
|
192
|
+
method: 'POST',
|
|
193
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/cancel`,
|
|
194
|
+
body: {},
|
|
195
|
+
})
|
|
196
|
+
.catch(() => {});
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
// 轮中转向:仅在 CoreLoop 路径(STEERABLE_USE_CORELOOP=1)且回合仍
|
|
200
|
+
// 运行时生效;否则主进程软失败,useChatStream 保留草稿。
|
|
201
|
+
steer: async (content: string) => {
|
|
202
|
+
const bridge = getElectronBridge();
|
|
203
|
+
if (!bridge?.localBackend.steerChat) return false;
|
|
204
|
+
return await bridge.localBackend.steerChat(chatId, content);
|
|
205
|
+
},
|
|
206
|
+
stream: async (input: ChatStreamSendInput, onEvent) => {
|
|
207
|
+
const bridge = getElectronBridge();
|
|
208
|
+
if (!bridge) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
'Electron bridge unavailable — chat transport requires the desktop shell.',
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const adapter = new LocalBackendSseAdapter(onEvent);
|
|
215
|
+
|
|
216
|
+
// We mutate this from inside the IPC callback (settle) and read it from
|
|
217
|
+
// the cancel handle returned at the end. Declaring it up-front sidesteps
|
|
218
|
+
// any temporal-dead-zone surprises.
|
|
219
|
+
const ref: { streamId: string | null } = { streamId: null };
|
|
220
|
+
|
|
221
|
+
await new Promise<void>((resolve, reject) => {
|
|
222
|
+
let settled = false;
|
|
223
|
+
const settle = (err?: Error) => {
|
|
224
|
+
if (settled) return;
|
|
225
|
+
settled = true;
|
|
226
|
+
if (err) reject(err);
|
|
227
|
+
else resolve();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
bridge.localBackend
|
|
231
|
+
.startStream(
|
|
232
|
+
{
|
|
233
|
+
method: 'POST',
|
|
234
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/run`,
|
|
235
|
+
body: {
|
|
236
|
+
message: input.content,
|
|
237
|
+
...(input.metadata ?? {}),
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
(payload) => {
|
|
241
|
+
if (payload.type === 'data') {
|
|
242
|
+
adapter.feed(payload.chunk);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (payload.type === 'end') {
|
|
246
|
+
active.streamId = null;
|
|
247
|
+
adapter.end();
|
|
248
|
+
settle();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// payload.type === 'error'
|
|
252
|
+
active.streamId = null;
|
|
253
|
+
adapter.end();
|
|
254
|
+
onEvent({ type: 'error', message: payload.error });
|
|
255
|
+
settle(new Error(payload.error));
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
.then((streamId) => {
|
|
259
|
+
ref.streamId = streamId;
|
|
260
|
+
active.streamId = streamId;
|
|
261
|
+
})
|
|
262
|
+
.catch((err: unknown) => {
|
|
263
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
264
|
+
onEvent({ type: 'error', message: msg });
|
|
265
|
+
settle(new Error(msg));
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Return cancel handle per the transport contract. Note: framework's
|
|
270
|
+
// hook stores this AFTER stream completion, so it's a no-op in practice.
|
|
271
|
+
// Real cancel-while-streaming goes through `cancelActive()` above.
|
|
272
|
+
return () => {
|
|
273
|
+
if (ref.streamId) bridge.localBackend.cancelStream(ref.streamId);
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Exported for unit testing the SSE adapter separately from the IPC bridge.
|
|
280
|
+
export const __test__ = {
|
|
281
|
+
LocalBackendSseAdapter,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* W1.2.1: regenerate an assistant turn via the backend's regenerate route
|
|
286
|
+
* (fork-preserving truncate-and-rerun). The stream's events are not fed to
|
|
287
|
+
* `useChatStream` — the hook's message state is thrown away on the
|
|
288
|
+
* re-hydrate that follows anyway, so this just runs the turn to completion
|
|
289
|
+
* and lets the caller remount the message list from the store.
|
|
290
|
+
*/
|
|
291
|
+
export async function regenerateChatMessage(
|
|
292
|
+
chatId: string,
|
|
293
|
+
messageId: string,
|
|
294
|
+
): Promise<void> {
|
|
295
|
+
const bridge = getElectronBridge();
|
|
296
|
+
if (!bridge) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
'Electron bridge unavailable — regenerate requires the desktop shell.',
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
await new Promise<void>((resolve, reject) => {
|
|
302
|
+
let settled = false;
|
|
303
|
+
const settle = (err?: Error) => {
|
|
304
|
+
if (settled) return;
|
|
305
|
+
settled = true;
|
|
306
|
+
if (err) reject(err);
|
|
307
|
+
else resolve();
|
|
308
|
+
};
|
|
309
|
+
// The route can refuse before running anything — it declines to regenerate
|
|
310
|
+
// when the old reply could not be forked into a branch. A refusal arrives
|
|
311
|
+
// as a named `error` frame plus a non-2xx `end`; the main process only
|
|
312
|
+
// sends `type: 'error'` when the handler throws, so without reading the
|
|
313
|
+
// frame the caller would see an ordinary completion.
|
|
314
|
+
let refusal: string | null = null;
|
|
315
|
+
const parser = new SSEParser({
|
|
316
|
+
onFrame: (frame) => {
|
|
317
|
+
if (frame.event !== 'error' || !frame.data) return;
|
|
318
|
+
const parsed = parseSSEData(frame.data);
|
|
319
|
+
const message = (parsed as { message?: unknown } | null)?.message;
|
|
320
|
+
refusal = typeof message === 'string' && message ? message : '重新生成失败';
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
bridge.localBackend
|
|
324
|
+
.startStream(
|
|
325
|
+
{
|
|
326
|
+
method: 'POST',
|
|
327
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}/regenerate`,
|
|
328
|
+
body: {},
|
|
329
|
+
},
|
|
330
|
+
(payload) => {
|
|
331
|
+
if (payload.type === 'data') parser.feed(payload.chunk);
|
|
332
|
+
else if (payload.type === 'end') {
|
|
333
|
+
parser.end();
|
|
334
|
+
settle(
|
|
335
|
+
payload.status >= 400
|
|
336
|
+
? new Error(refusal ?? `重新生成失败(HTTP ${payload.status})`)
|
|
337
|
+
: undefined,
|
|
338
|
+
);
|
|
339
|
+
} else if (payload.type === 'error') settle(new Error(payload.error));
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
.catch((err: unknown) => {
|
|
343
|
+
settle(err instanceof Error ? err : new Error(String(err)));
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date-group helpers used by AgentSidebar to label chat groups
|
|
3
|
+
* ("今天" / "昨天" / "N天前" / "M月D日" / "YYYY年M月D日"). Lifted verbatim
|
|
4
|
+
* from `deeppath/apps/web/src/app/agent/AgentSidebar.tsx` so the visual
|
|
5
|
+
* grouping stays in lockstep.
|
|
6
|
+
*
|
|
7
|
+
* priority is used for ordering groups: smaller = newer = listed first.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function getDateGroupLabel(date: Date): string {
|
|
11
|
+
const today = new Date();
|
|
12
|
+
today.setHours(0, 0, 0, 0);
|
|
13
|
+
const target = new Date(date);
|
|
14
|
+
target.setHours(0, 0, 0, 0);
|
|
15
|
+
const diffDays =
|
|
16
|
+
(today.getTime() - target.getTime()) / (1000 * 60 * 60 * 24);
|
|
17
|
+
if (diffDays === 0) return '今天';
|
|
18
|
+
if (diffDays === 1) return '昨天';
|
|
19
|
+
if (diffDays < 4) return `${Math.floor(diffDays)}天前`;
|
|
20
|
+
const m = target.getMonth() + 1;
|
|
21
|
+
const d = target.getDate();
|
|
22
|
+
if (target.getFullYear() !== today.getFullYear()) {
|
|
23
|
+
return `${target.getFullYear()}年${m}月${d}日`;
|
|
24
|
+
}
|
|
25
|
+
return `${m}月${d}日`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getDateGroupPriority(label: string): number {
|
|
29
|
+
if (label === '今天') return 1;
|
|
30
|
+
if (label === '昨天') return 2;
|
|
31
|
+
if (label.endsWith('天前')) return 2 + parseInt(label, 10);
|
|
32
|
+
if (label.includes('年')) return 1000;
|
|
33
|
+
return 100;
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo-mode flag for the static website build (steerableframework.com/demo/).
|
|
3
|
+
* The app-demo entry calls markDemoMode() before bootstrap; AppShell reads it
|
|
4
|
+
* for the demo banner and the browser mock reads it to allow installation
|
|
5
|
+
* outside DEV builds. Normal app builds never call markDemoMode().
|
|
6
|
+
*/
|
|
7
|
+
let demoMode = false;
|
|
8
|
+
|
|
9
|
+
export function markDemoMode(): void {
|
|
10
|
+
demoMode = true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isDemoMode(): boolean {
|
|
14
|
+
return demoMode;
|
|
15
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed view of `window.electron`, the bridge that Electron's preload script
|
|
3
|
+
* (src/preload.ts in the main project) exposes via `contextBridge`.
|
|
4
|
+
*
|
|
5
|
+
* This file MUST stay a subset / aligned shape with `ElectronAPI` in
|
|
6
|
+
* `../../../src/preload.ts`. We intentionally don't TypeScript-reference that
|
|
7
|
+
* file across workspaces — it would drag the whole main-process source tree
|
|
8
|
+
* into the renderer's compile graph. Treat this as a contract surface and
|
|
9
|
+
* extend it lazily as apps/web starts using new bridge methods.
|
|
10
|
+
*
|
|
11
|
+
* Outside Electron (e.g. plain `vite dev` in a browser tab without preload),
|
|
12
|
+
* `window.electron` is undefined. Always read it via `getElectronBridge()`
|
|
13
|
+
* and handle the `null` case so the SPA stays demoable in a normal browser.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { SSEEvent } from '@steerable/agent-protocol';
|
|
17
|
+
import { getHttpBridge } from './http-bridge';
|
|
18
|
+
|
|
19
|
+
export type LocalBackendRequestInput = {
|
|
20
|
+
method: string;
|
|
21
|
+
path: string;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type LocalBackendStreamEvent =
|
|
26
|
+
| { type: 'data'; chunk: string; parsed?: SSEEvent }
|
|
27
|
+
| { type: 'end'; status: number }
|
|
28
|
+
| { type: 'error'; error: string };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* W4-1: mirror of the approval algebra's decision variants
|
|
32
|
+
* (`steerable_agent_runtime.approval.APPROVAL_KINDS` minus `timed_out`,
|
|
33
|
+
* which the sidecar synthesizes itself on timeout).
|
|
34
|
+
*/
|
|
35
|
+
export type ApprovalDecisionKind =
|
|
36
|
+
| 'allow_once'
|
|
37
|
+
| 'allow_for_session'
|
|
38
|
+
| 'allow_always'
|
|
39
|
+
| 'deny_once'
|
|
40
|
+
| 'deny_for_session'
|
|
41
|
+
| 'deny_always'
|
|
42
|
+
| 'abort';
|
|
43
|
+
|
|
44
|
+
export interface ApprovalPromptRequest {
|
|
45
|
+
requestId: string;
|
|
46
|
+
toolName: string;
|
|
47
|
+
arguments: Record<string, unknown>;
|
|
48
|
+
mode: string;
|
|
49
|
+
category: string;
|
|
50
|
+
round: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* W8: mirror of the sidecar's `ask_user.request` reverse-call payload
|
|
55
|
+
* (`HostAskUserHandler` in steerable_sidecar.host_tools). `questions`
|
|
56
|
+
* entries match `AskUserQuestionsPayload['questions'][number]` from
|
|
57
|
+
* `@steerable/agent-protocol`; kept structurally typed here so the
|
|
58
|
+
* renderer's compile graph stays free of main-process imports.
|
|
59
|
+
*/
|
|
60
|
+
export interface AskUserPromptRequest {
|
|
61
|
+
requestId: string;
|
|
62
|
+
intro: string;
|
|
63
|
+
questions: Array<Record<string, unknown>>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mirror of `TerminalSession` / `TerminalSpawnOptions` in
|
|
68
|
+
* `src/terminal-manager.ts`. Kept inline (rather than cross-imported)
|
|
69
|
+
* for the same reason as the rest of this file — we don't want the
|
|
70
|
+
* renderer's compile graph to pull in main-process modules.
|
|
71
|
+
*/
|
|
72
|
+
export interface TerminalSession {
|
|
73
|
+
id: string;
|
|
74
|
+
shell: string;
|
|
75
|
+
pid: number;
|
|
76
|
+
cwd: string;
|
|
77
|
+
cols: number;
|
|
78
|
+
rows: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface TerminalSpawnOptions {
|
|
82
|
+
shell?: string;
|
|
83
|
+
cwd?: string;
|
|
84
|
+
env?: Record<string, string>;
|
|
85
|
+
cols?: number;
|
|
86
|
+
rows?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ElectronBridge {
|
|
90
|
+
runtime: 'local';
|
|
91
|
+
platform: NodeJS.Platform;
|
|
92
|
+
local?: {
|
|
93
|
+
selectDirectory: (options?: {
|
|
94
|
+
title?: string;
|
|
95
|
+
}) => Promise<{ canceled: boolean; filePaths: string[] }>;
|
|
96
|
+
saveTextFile?: (options: {
|
|
97
|
+
title?: string;
|
|
98
|
+
defaultPath?: string;
|
|
99
|
+
content: string;
|
|
100
|
+
}) => Promise<{ canceled: boolean; filePath?: string }>;
|
|
101
|
+
/** 截取窗口(或指定 DIP 区域)并写入系统剪贴板。 */
|
|
102
|
+
captureScreenshot: (rect?: {
|
|
103
|
+
x: number;
|
|
104
|
+
y: number;
|
|
105
|
+
width: number;
|
|
106
|
+
height: number;
|
|
107
|
+
}) => Promise<
|
|
108
|
+
| { success: true; width: number; height: number }
|
|
109
|
+
| { success: false; error: string }
|
|
110
|
+
>;
|
|
111
|
+
};
|
|
112
|
+
localBackend: {
|
|
113
|
+
request: <T>(input: LocalBackendRequestInput) => Promise<T>;
|
|
114
|
+
startStream: (
|
|
115
|
+
input: LocalBackendRequestInput,
|
|
116
|
+
onEvent: (payload: LocalBackendStreamEvent) => void,
|
|
117
|
+
) => Promise<string | null>;
|
|
118
|
+
cancelStream: (streamId: string) => void;
|
|
119
|
+
/** 轮中转向:注入一条用户消息到运行中的 CoreLoop 回合;false = 无可转向回合。 */
|
|
120
|
+
steerChat?: (chatId: string, content: string) => Promise<boolean>;
|
|
121
|
+
};
|
|
122
|
+
/** 会话附件:把源文件路径 / base64 字节拷贝进会话空间(`attachments:save`)。 */
|
|
123
|
+
attachments?: {
|
|
124
|
+
save: (input: {
|
|
125
|
+
chatId: string;
|
|
126
|
+
files: Array<{ path?: string; name?: string; data?: string }>;
|
|
127
|
+
}) => Promise<{
|
|
128
|
+
files: Array<{ name: string; path: string; size: number; error?: string }>;
|
|
129
|
+
}>;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* App menu IPC — `Cmd+N` / 文件 → 新建对话 fires `menu:new-chat`
|
|
133
|
+
* (see src/main.ts:createMenu). Renderer subscribes via
|
|
134
|
+
* `onMenuNewChat(cb)` and *must* call `offMenuNewChat()` on unmount
|
|
135
|
+
* to clear the listener (otherwise multiple AgentSidebar mounts
|
|
136
|
+
* during HMR would queue up duplicate callbacks).
|
|
137
|
+
*/
|
|
138
|
+
onMenuNewChat?: (callback: () => void) => void;
|
|
139
|
+
offMenuNewChat?: () => void;
|
|
140
|
+
/**
|
|
141
|
+
* App menu IPC — `Cmd+T` / 视图 → 打开终端 fires `menu:open-terminal`.
|
|
142
|
+
* The terminal is a toggleable panel inside AgentLayout (not a separate
|
|
143
|
+
* window, not a route), so the renderer just flips layout state. Same
|
|
144
|
+
* subscribe/off pairing as `onMenuNewChat`.
|
|
145
|
+
*/
|
|
146
|
+
onMenuOpenTerminal?: (callback: () => void) => void;
|
|
147
|
+
offMenuOpenTerminal?: () => void;
|
|
148
|
+
/**
|
|
149
|
+
* 异步 AI 标题就绪通知。Backend 在每条 chat 首条助手回复完成后 fire-and-forget
|
|
150
|
+
* 跑 LLM 生成标题;完成时通过这个通道把 `{chatId, title}` 推过来。返回的函数
|
|
151
|
+
* 解除订阅(preload 用 add/remove 配对,不是 removeAllListeners——可以多处订阅)。
|
|
152
|
+
*/
|
|
153
|
+
onChatTitleUpdated?: (
|
|
154
|
+
callback: (payload: { chatId: string; title: string }) => void,
|
|
155
|
+
) => () => void;
|
|
156
|
+
/**
|
|
157
|
+
* 会话补建通知。Backend 在「向一个本地不存在的 chatId 首次发送」时按 URL
|
|
158
|
+
* 里的 id 现场补建会话(见 router.handleStream),随后广播 `chat-created`
|
|
159
|
+
* ({chatId, agentId});渲染端据此 refresh 一次侧栏,避免"URL 能聊、列表
|
|
160
|
+
* 里却找不到这条会话"。返回解除订阅函数。
|
|
161
|
+
*/
|
|
162
|
+
onChatCreated?: (
|
|
163
|
+
callback: (payload: { chatId: string; agentId?: string | null }) => void,
|
|
164
|
+
) => () => void;
|
|
165
|
+
/**
|
|
166
|
+
* 4.6a 后台任务状态推送。任务到达终态或 worktree 合并/丢弃完成时主进程
|
|
167
|
+
* 广播;载荷只有 chatId/taskId/status,面板收到后重新拉列表。
|
|
168
|
+
*/
|
|
169
|
+
onTaskUpdated?: (
|
|
170
|
+
callback: (payload: { chatId: string; taskId: string; status: string }) => void,
|
|
171
|
+
) => () => void;
|
|
172
|
+
/**
|
|
173
|
+
* 场景包广播事件订阅(3.1 通用化):按通道名订阅主进程/BS 后端的
|
|
174
|
+
* 广播事件,载荷由订阅方(包)自行收窄校验。shell 不知道包的事件
|
|
175
|
+
* 语义——包的通道名约定带包前缀(如 `<pack>:updated`)。返回解除
|
|
176
|
+
* 订阅函数。
|
|
177
|
+
*/
|
|
178
|
+
onPackEvent?: (
|
|
179
|
+
channel: string,
|
|
180
|
+
callback: (payload: unknown) => void,
|
|
181
|
+
) => () => void;
|
|
182
|
+
/**
|
|
183
|
+
* 后台任务推理时间线推送。任务流的 reasoning / 工具 / 文本增量经主进程
|
|
184
|
+
* 广播;右侧过程栏订阅后不用轮询。
|
|
185
|
+
*/
|
|
186
|
+
onTaskProcess?: (
|
|
187
|
+
callback: (payload: {
|
|
188
|
+
chatId: string;
|
|
189
|
+
taskId: string;
|
|
190
|
+
timeline: unknown;
|
|
191
|
+
live: boolean;
|
|
192
|
+
}) => void,
|
|
193
|
+
) => () => void;
|
|
194
|
+
/**
|
|
195
|
+
* W4-1 审批代数:sidecar ApprovalExecutor 的请示经主进程广播到 renderer。
|
|
196
|
+
* onRequest 订阅返回 unsubscribe;decide 把用户的 7 变体决定送回主进程。
|
|
197
|
+
*/
|
|
198
|
+
approval?: {
|
|
199
|
+
onRequest: (callback: (request: ApprovalPromptRequest) => void) => () => void;
|
|
200
|
+
decide: (decision: {
|
|
201
|
+
requestId: string;
|
|
202
|
+
kind: ApprovalDecisionKind;
|
|
203
|
+
reason?: string;
|
|
204
|
+
}) => Promise<void>;
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* W8 结构化提问:sidecar ask_user 工具的请示经主进程/BS 服务器广播到
|
|
208
|
+
* renderer。onRequest 订阅返回 unsubscribe;answer 把答案映射送回
|
|
209
|
+
* (空映射 = 用户未作答,模型自行推进)。
|
|
210
|
+
*/
|
|
211
|
+
askUser?: {
|
|
212
|
+
onRequest: (callback: (request: AskUserPromptRequest) => void) => () => void;
|
|
213
|
+
answer: (reply: {
|
|
214
|
+
requestId: string;
|
|
215
|
+
answers: Record<string, string | string[]>;
|
|
216
|
+
}) => Promise<void>;
|
|
217
|
+
};
|
|
218
|
+
terminal?: {
|
|
219
|
+
/** Ensures a session exists (reuses last one if alive). */
|
|
220
|
+
ensure: (options?: TerminalSpawnOptions) => Promise<TerminalSession>;
|
|
221
|
+
write: (id: string, data: string) => Promise<boolean>;
|
|
222
|
+
resize: (id: string, cols: number, rows: number) => Promise<boolean>;
|
|
223
|
+
onData: (
|
|
224
|
+
callback: (payload: { sessionId: string; chunk: string }) => void,
|
|
225
|
+
) => () => void;
|
|
226
|
+
onExit: (
|
|
227
|
+
callback: (payload: {
|
|
228
|
+
sessionId: string;
|
|
229
|
+
code: number;
|
|
230
|
+
signal: string | null;
|
|
231
|
+
}) => void,
|
|
232
|
+
) => () => void;
|
|
233
|
+
/**
|
|
234
|
+
* agent 命令进入可见 PTY 时由 main 广播(`terminal:reveal`)——渲染端
|
|
235
|
+
* 据此自动展开内嵌终端面板(Codex 式 auto-reveal)。已打开时是 no-op。
|
|
236
|
+
*/
|
|
237
|
+
onReveal: (
|
|
238
|
+
callback: (payload: { sessionId: string }) => void,
|
|
239
|
+
) => () => void;
|
|
240
|
+
};
|
|
241
|
+
// 场景包的 invoke 命名空间(如包 preload 贡献的 `<pack>` / `<pack>Mock`)
|
|
242
|
+
// 不进本接口——包用自己的结构化收窄访问器(见各包 web/bridge.ts),
|
|
243
|
+
// shell 桥接口保持产品中立(3.1)。
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
declare global {
|
|
247
|
+
interface Window {
|
|
248
|
+
electron?: ElectronBridge;
|
|
249
|
+
/**
|
|
250
|
+
* BS server(src/server/)托管 index.html 时注入的引导信息;存在即代表
|
|
251
|
+
* 当前跑在浏览器-服务器模式,getElectronBridge() 会返回 HTTP 实现。
|
|
252
|
+
*/
|
|
253
|
+
__DEEPPATH_BS__?: {
|
|
254
|
+
platform: NodeJS.Platform;
|
|
255
|
+
flavor: string;
|
|
256
|
+
brandName: string;
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function getElectronBridge(): ElectronBridge | null {
|
|
262
|
+
if (typeof window === 'undefined') return null;
|
|
263
|
+
if (window.electron) return window.electron;
|
|
264
|
+
if (window.__DEEPPATH_BS__) return getHttpBridge();
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function isElectron(): boolean {
|
|
269
|
+
return getElectronBridge() !== null;
|
|
270
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话级命令执行沙箱(类 Codex 的 sandbox_mode)。
|
|
3
|
+
*
|
|
4
|
+
* 作用于下一轮 `chat.stream` 的 `execSandbox`,不是 sidecar 进程沙箱
|
|
5
|
+
* (设置页那一层)。工作区 = 只能写入当前项目;完整权限 = 关闭逐条命令
|
|
6
|
+
* 沙箱。工具审批(允许/拒绝)仍按原策略生效。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type ExecPolicy = 'workspace' | 'full';
|
|
10
|
+
|
|
11
|
+
export const EXEC_POLICY_STORAGE_KEY = 'agent-exec-policy';
|
|
12
|
+
|
|
13
|
+
export function parseExecPolicy(value: unknown): ExecPolicy {
|
|
14
|
+
return value === 'full' ? 'full' : 'workspace';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readStoredExecPolicy(): ExecPolicy {
|
|
18
|
+
if (typeof localStorage === 'undefined') return 'workspace';
|
|
19
|
+
return parseExecPolicy(localStorage.getItem(EXEC_POLICY_STORAGE_KEY));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function persistExecPolicy(policy: ExecPolicy): void {
|
|
23
|
+
if (typeof localStorage === 'undefined') return;
|
|
24
|
+
localStorage.setItem(EXEC_POLICY_STORAGE_KEY, policy);
|
|
25
|
+
}
|