@steerable/agent-shell-web 0.6.20 → 0.6.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/components/chat/ApprovalModal.tsx +21 -41
- package/src/components/chat/ApprovalPromptProvider.tsx +98 -0
- package/src/components/chat/AskUserPromptProvider.tsx +99 -0
- package/src/components/chat/AssistantMessage.tsx +25 -2
- package/src/components/chat/ChatInput.tsx +116 -13
- package/src/components/chat/FilePathCode.tsx +65 -0
- package/src/components/chat/LocalChatPanel.tsx +19 -0
- package/src/components/chat/Markdown.tsx +54 -25
- package/src/components/chat/MessageList.tsx +66 -18
- package/src/components/chat/ModelPicker.tsx +5 -3
- package/src/components/chat/SuggestedReplies.tsx +34 -0
- package/src/components/chat/TurnFilesCard.tsx +113 -0
- package/src/components/chat/TurnProcessGroup.tsx +7 -2
- package/src/components/chat/path-mentions.ts +148 -0
- package/src/components/chat/suggested-replies-model.ts +40 -0
- package/src/components/chat/turn-files.ts +50 -0
- package/src/layouts/AgentLayout.tsx +15 -26
- package/src/lib/browser-dev-electron-mock.ts +15 -0
- package/src/lib/electron-bridge.ts +13 -7
- package/src/lib/http-bridge.ts +8 -2
- package/src/lib/local-api.ts +31 -0
- package/src/pages/AgentPage.tsx +126 -1
- package/src/pages/ChatPanelPreviewPage.tsx +4 -0
- package/src/components/chat/AskUserModalHost.tsx +0 -61
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 正文里提到的文件路径:识别 + 存在性解析缓存。
|
|
3
|
+
*
|
|
4
|
+
* 助手回答里的路径通常写在行内代码里(`` `./报告.docx` ``)。这里先用
|
|
5
|
+
* 廉价的形状判断挑出候选,再交给后端 stat 证伪——路径是模型写出来的自然
|
|
6
|
+
* 语言片段,形状判断必然有误报,只有后端确认存在的才会变成可点击。
|
|
7
|
+
*
|
|
8
|
+
* 缓存是模块级的:流式渲染期间同一个代码节点会重渲染很多次,没有缓存和
|
|
9
|
+
* 请求合并会把 resolve-paths 打成风暴。同一 (chatId, 字面量) 只解析一次,
|
|
10
|
+
* 同一批渲染里的多个候选合并成一次请求。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { resolveLocalPaths, type ResolvedLocalPath } from '@/lib/local-api';
|
|
14
|
+
|
|
15
|
+
/** 协议前缀(http:// 等)不是本地路径。 */
|
|
16
|
+
const URL_LIKE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
17
|
+
/** `1.2.3` / `v0.6.20` 这类版本号会被「带扩展名的裸文件名」规则误收。 */
|
|
18
|
+
const VERSION_LIKE = /^v?\d+(\.\d+)+$/;
|
|
19
|
+
/** Windows 绝对路径:`C:\x` / `C:/x`。 */
|
|
20
|
+
const WINDOWS_ABSOLUTE = /^[A-Za-z]:[\\/]/;
|
|
21
|
+
/** 不含分隔符但带扩展名的裸文件名:`报告.docx`。 */
|
|
22
|
+
const BARE_FILE_NAME = /^[^\\/<>:"|?*]+\.[A-Za-z0-9]{1,10}$/;
|
|
23
|
+
|
|
24
|
+
/** 单条候选的长度上限(与后端一致)。 */
|
|
25
|
+
const MAX_CANDIDATE_LENGTH = 512;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 形状上像不像本地文件/目录路径。只做廉价判断,存在性由后端把关。
|
|
29
|
+
* 含空白的一律否决——行内代码里带空格的多是命令(`npm install`),
|
|
30
|
+
* 带空格的真实路径在正文里极少,误伤远小于把命令渲染成可点击。
|
|
31
|
+
*/
|
|
32
|
+
export function looksLikeFilePath(text: string): boolean {
|
|
33
|
+
const candidate = text.trim();
|
|
34
|
+
if (!candidate || candidate.length > MAX_CANDIDATE_LENGTH) return false;
|
|
35
|
+
if (/\s/.test(candidate)) return false;
|
|
36
|
+
if (URL_LIKE.test(candidate)) return false;
|
|
37
|
+
if (VERSION_LIKE.test(candidate)) return false;
|
|
38
|
+
if (candidate.startsWith('~/') || WINDOWS_ABSOLUTE.test(candidate)) return true;
|
|
39
|
+
if (candidate.includes('/')) return true;
|
|
40
|
+
return BARE_FILE_NAME.test(candidate);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** null = 后端确认过但不存在(不可点击);undefined = 还没解析。 */
|
|
44
|
+
type CacheEntry = ResolvedLocalPath | null;
|
|
45
|
+
|
|
46
|
+
const cache = new Map<string, CacheEntry>();
|
|
47
|
+
const waiters = new Map<string, Set<(entry: CacheEntry) => void>>();
|
|
48
|
+
const queued = new Map<string, Set<string>>();
|
|
49
|
+
let flushHandle: ReturnType<typeof setTimeout> | null = null;
|
|
50
|
+
|
|
51
|
+
function cacheKey(candidate: string, chatId: string): string {
|
|
52
|
+
return `${chatId}\u0000${candidate}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 已解析过就直接返回结果,否则返回 undefined(调用方需要订阅)。 */
|
|
56
|
+
export function peekResolvedPath(
|
|
57
|
+
candidate: string,
|
|
58
|
+
chatId?: string | null,
|
|
59
|
+
): CacheEntry | undefined {
|
|
60
|
+
return cache.get(cacheKey(candidate.trim(), chatId ?? ''));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 订阅一个候选的解析结果。已有缓存时同步回调;否则入队,同一批渲染的候选
|
|
65
|
+
* 合并成一次请求。返回取消订阅函数(组件卸载后不再回调)。
|
|
66
|
+
*/
|
|
67
|
+
export function subscribeResolvedPath(
|
|
68
|
+
candidate: string,
|
|
69
|
+
chatId: string | null | undefined,
|
|
70
|
+
onResolve: (entry: CacheEntry) => void,
|
|
71
|
+
): () => void {
|
|
72
|
+
const trimmed = candidate.trim();
|
|
73
|
+
const scope = chatId ?? '';
|
|
74
|
+
const key = cacheKey(trimmed, scope);
|
|
75
|
+
const cached = cache.get(key);
|
|
76
|
+
if (cached !== undefined) {
|
|
77
|
+
onResolve(cached);
|
|
78
|
+
return () => {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let listeners = waiters.get(key);
|
|
82
|
+
if (!listeners) {
|
|
83
|
+
listeners = new Set();
|
|
84
|
+
waiters.set(key, listeners);
|
|
85
|
+
}
|
|
86
|
+
listeners.add(onResolve);
|
|
87
|
+
|
|
88
|
+
let scoped = queued.get(scope);
|
|
89
|
+
if (!scoped) {
|
|
90
|
+
scoped = new Set();
|
|
91
|
+
queued.set(scope, scoped);
|
|
92
|
+
}
|
|
93
|
+
scoped.add(trimmed);
|
|
94
|
+
scheduleFlush();
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
listeners?.delete(onResolve);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function scheduleFlush(): void {
|
|
102
|
+
if (flushHandle !== null) return;
|
|
103
|
+
flushHandle = setTimeout(() => {
|
|
104
|
+
flushHandle = null;
|
|
105
|
+
void flushQueued();
|
|
106
|
+
}, 0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function flushQueued(): Promise<void> {
|
|
110
|
+
const batches = [...queued.entries()];
|
|
111
|
+
queued.clear();
|
|
112
|
+
for (const [scope, candidates] of batches) {
|
|
113
|
+
const list = [...candidates];
|
|
114
|
+
if (list.length === 0) continue;
|
|
115
|
+
let resolved: ResolvedLocalPath[] = [];
|
|
116
|
+
try {
|
|
117
|
+
const response = await resolveLocalPaths(list, scope || null);
|
|
118
|
+
resolved = response.resolved ?? [];
|
|
119
|
+
} catch {
|
|
120
|
+
// 解析失败(后端不可达等)当作「不存在」落缓存:正文照旧渲染成
|
|
121
|
+
// 普通行内代码,不给用户一个点不开的假affordance。
|
|
122
|
+
resolved = [];
|
|
123
|
+
}
|
|
124
|
+
const byCandidate = new Map(resolved.map((item) => [item.candidate, item]));
|
|
125
|
+
for (const candidate of list) {
|
|
126
|
+
settle(cacheKey(candidate, scope), byCandidate.get(candidate) ?? null);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function settle(key: string, entry: CacheEntry): void {
|
|
132
|
+
cache.set(key, entry);
|
|
133
|
+
const listeners = waiters.get(key);
|
|
134
|
+
waiters.delete(key);
|
|
135
|
+
if (!listeners) return;
|
|
136
|
+
for (const listener of listeners) listener(entry);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 测试用:清空模块级缓存与待解析队列。 */
|
|
140
|
+
export function resetPathMentionCache(): void {
|
|
141
|
+
cache.clear();
|
|
142
|
+
waiters.clear();
|
|
143
|
+
queued.clear();
|
|
144
|
+
if (flushHandle !== null) {
|
|
145
|
+
clearTimeout(flushHandle);
|
|
146
|
+
flushHandle = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从助手消息 metadata 读出持久化的追问建议(刷新后仍能画在最后一条回复下)。
|
|
3
|
+
*/
|
|
4
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
5
|
+
|
|
6
|
+
export interface SuggestedRepliesState {
|
|
7
|
+
messageId: string;
|
|
8
|
+
suggestions: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readSuggestedReplies(metadata: unknown): string[] | null {
|
|
12
|
+
if (typeof metadata !== 'string' || !metadata) return null;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(metadata) as { suggestedReplies?: unknown };
|
|
15
|
+
if (
|
|
16
|
+
Array.isArray(parsed.suggestedReplies) &&
|
|
17
|
+
parsed.suggestedReplies.length > 0 &&
|
|
18
|
+
parsed.suggestedReplies.every((item) => typeof item === 'string' && item.trim())
|
|
19
|
+
) {
|
|
20
|
+
return parsed.suggestedReplies.map((item) => item.trim()).slice(0, 3);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 从新→旧找最近一条带 suggestedReplies 的助手消息。 */
|
|
29
|
+
export function extractLatestSuggestedReplies(
|
|
30
|
+
messages: Array<ChatMessage & { messageMetadata?: string | null }> | undefined,
|
|
31
|
+
): SuggestedRepliesState | null {
|
|
32
|
+
if (!messages || messages.length === 0) return null;
|
|
33
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
34
|
+
const message = messages[i];
|
|
35
|
+
if (message.role !== 'assistant') continue;
|
|
36
|
+
const suggestions = readSuggestedReplies(message.messageMetadata);
|
|
37
|
+
if (suggestions) return { messageId: message.id, suggestions };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合产物文件列表(「本轮写了哪些文件」)的前端模型。
|
|
3
|
+
*
|
|
4
|
+
* 数据源:local-backend 在回合收尾时扫描可写根 + 写工具参数并集(见
|
|
5
|
+
* `src/local-backend/turn-files.ts`),经 `turn_files` SSE 事件下发,并
|
|
6
|
+
* 持久化进助手消息的 messageMetadata(键 `turnFiles`)供刷新后水合。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface TurnFile {
|
|
10
|
+
/** 绝对路径 —— 点击打开直接传给后端。 */
|
|
11
|
+
path: string;
|
|
12
|
+
/** created = 本轮新建;modified = 已有文件被改动。 */
|
|
13
|
+
kind: 'created' | 'modified';
|
|
14
|
+
/** 字节数(展示用,可缺省)。 */
|
|
15
|
+
size?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 解析 SSE 事件 / 持久化元数据里的文件列表;形状不符返回 null(与 parseTurnBlocks 同约定)。 */
|
|
19
|
+
export function parseTurnFiles(raw: unknown): TurnFile[] | null {
|
|
20
|
+
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
21
|
+
const files: TurnFile[] = [];
|
|
22
|
+
for (const item of raw) {
|
|
23
|
+
if (!item || typeof item !== 'object') return null;
|
|
24
|
+
const rec = item as Record<string, unknown>;
|
|
25
|
+
if (typeof rec.path !== 'string' || !rec.path) return null;
|
|
26
|
+
if (rec.kind !== 'created' && rec.kind !== 'modified') return null;
|
|
27
|
+
files.push({
|
|
28
|
+
path: rec.path,
|
|
29
|
+
kind: rec.kind,
|
|
30
|
+
...(typeof rec.size === 'number' ? { size: rec.size } : {}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return files;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 文件名的展示分段:basename 加粗、目录部分弱化。 */
|
|
37
|
+
export function splitTurnFilePath(filePath: string): { dir: string; name: string } {
|
|
38
|
+
const normalized = filePath.replace(/[/\\]+$/, '');
|
|
39
|
+
const idx = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\'));
|
|
40
|
+
if (idx < 0) return { dir: '', name: normalized };
|
|
41
|
+
return { dir: normalized.slice(0, idx + 1), name: normalized.slice(idx + 1) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 紧凑的字节数展示(不足 1 KB 显示 B)。 */
|
|
45
|
+
export function formatFileSize(size: number | undefined): string | null {
|
|
46
|
+
if (size == null || !Number.isFinite(size) || size < 0) return null;
|
|
47
|
+
if (size < 1024) return `${size} B`;
|
|
48
|
+
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
|
49
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
50
|
+
}
|
|
@@ -4,8 +4,8 @@ import { LuPanelLeftOpen } from 'react-icons/lu';
|
|
|
4
4
|
import { AgentSidebar } from '@/components/AgentSidebar';
|
|
5
5
|
import { TerminalPanel } from '@/components/TerminalPanel';
|
|
6
6
|
import { TaskProcessPanel, type InspectedTask } from '@/components/chat/TaskProcessPanel';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { AskUserPromptProvider } from '@/components/chat/AskUserPromptProvider';
|
|
8
|
+
import { ApprovalPromptProvider } from '@/components/chat/ApprovalPromptProvider';
|
|
9
9
|
import { InsightsConsentBanner } from '@/components/settings/InsightsSettingsPanel';
|
|
10
10
|
import { trackBehavior } from '@/lib/insights';
|
|
11
11
|
import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
@@ -32,9 +32,8 @@ import {
|
|
|
32
32
|
* view stays mounted while the user switches chats. Toggled from the
|
|
33
33
|
* sidebar 终端 button or Cmd+T (`menu:open-terminal`, subscribed in
|
|
34
34
|
* AgentSidebar). Open state + width persist to localStorage.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* opens the panel (idempotent) so the user watches the agent type.
|
|
35
|
+
* Agent shell commands still run in the shared PTY; the panel is not
|
|
36
|
+
* auto-opened when that happens.
|
|
38
37
|
* - Closing the panel only unmounts the xterm VIEW; the PTY session is
|
|
39
38
|
* a main-process singleton and keeps running. Reopening replays the
|
|
40
39
|
* output buffer via `terminal:ensure`.
|
|
@@ -89,7 +88,7 @@ export type AgentOutletContext = UseChatsAndAgentsResult & {
|
|
|
89
88
|
inspectTask: (task: InspectedTask) => void;
|
|
90
89
|
};
|
|
91
90
|
|
|
92
|
-
|
|
91
|
+
function AgentLayoutContent() {
|
|
93
92
|
const data = useChatsAndAgents();
|
|
94
93
|
const { chatId } = useParams<{ chatId?: string }>();
|
|
95
94
|
|
|
@@ -370,20 +369,6 @@ export function AgentLayout() {
|
|
|
370
369
|
persistRightPanel('terminal');
|
|
371
370
|
}, [persistRightPanel]);
|
|
372
371
|
|
|
373
|
-
// Codex 式 auto-reveal:agent 的命令进入可见 PTY 时,main 广播
|
|
374
|
-
// `terminal:reveal`,这里自动展开终端面板。幂等——已打开时是 no-op;
|
|
375
|
-
// 用户手动关掉后,下一条命令会再次拉开(符合"看 agent 打字"的意图)。
|
|
376
|
-
useEffect(() => {
|
|
377
|
-
const bridge = getElectronBridge();
|
|
378
|
-
if (!bridge?.terminal?.onReveal) return;
|
|
379
|
-
return bridge.terminal.onReveal(() => {
|
|
380
|
-
setInspectedTask(null);
|
|
381
|
-
rightPanelRef.current = 'terminal';
|
|
382
|
-
setRightPanelState('terminal');
|
|
383
|
-
persistRightPanel('terminal');
|
|
384
|
-
});
|
|
385
|
-
}, [persistRightPanel]);
|
|
386
|
-
|
|
387
372
|
// 包槽位的自动展开(如文档包:后端在本轮产出新设计稿时广播包事件)。
|
|
388
373
|
// 互斥规则:仅当栏位空闲(null)时 reveal 才生效;
|
|
389
374
|
// 已打开任一面板时,自动展开请求被忽略。订阅逻辑由包自己实现。
|
|
@@ -563,12 +548,16 @@ export function AgentLayout() {
|
|
|
563
548
|
)}
|
|
564
549
|
</div>
|
|
565
550
|
</div>
|
|
566
|
-
{/* W4-1 审批弹窗挂在 layout 层而非 chat 视图:审批请求属于正在跑的
|
|
567
|
-
回合,回合不因用户切走页面而暂停——挂在 AgentChatView 下时,用户
|
|
568
|
-
导航到首页/别的对话会让模态永不渲染,请求只能等超时 fail-closed。 */}
|
|
569
|
-
{isElectron() && <ApprovalModalHost />}
|
|
570
|
-
{/* W8 提问卡片同理:挂在 layout 层,页面切换不丢待答问题。 */}
|
|
571
|
-
{isElectron() && <AskUserModalHost />}
|
|
572
551
|
</div>
|
|
573
552
|
);
|
|
574
553
|
}
|
|
554
|
+
|
|
555
|
+
export function AgentLayout() {
|
|
556
|
+
return (
|
|
557
|
+
<ApprovalPromptProvider>
|
|
558
|
+
<AskUserPromptProvider>
|
|
559
|
+
<AgentLayoutContent />
|
|
560
|
+
</AskUserPromptProvider>
|
|
561
|
+
</ApprovalPromptProvider>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
@@ -39,6 +39,10 @@ interface BrowserMockState {
|
|
|
39
39
|
fixtureMeta: Pick<BrowserDevFixture, 'exportedAt' | 'source'> | null;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
const suggestedReplyListeners = new Set<
|
|
43
|
+
(payload: { chatId: string; messageId: string; suggestions: string[] }) => void
|
|
44
|
+
>();
|
|
45
|
+
|
|
42
46
|
const USER_ID = 'browser-preview-user';
|
|
43
47
|
const now = new Date();
|
|
44
48
|
|
|
@@ -576,6 +580,7 @@ async function startStream(
|
|
|
576
580
|
{ type: 'text', content: reply },
|
|
577
581
|
],
|
|
578
582
|
durationMs: 1_200,
|
|
583
|
+
suggestedReplies: ['继续完善这份结果', '换一种呈现方式', '告诉我下一步怎么做'],
|
|
579
584
|
}),
|
|
580
585
|
});
|
|
581
586
|
assistantMessage.createdAt = new Date().toISOString();
|
|
@@ -588,6 +593,10 @@ async function startStream(
|
|
|
588
593
|
pushSse(onEvent, '[DONE]');
|
|
589
594
|
onEvent({ type: 'end', status: 200 });
|
|
590
595
|
state.activeStreams.delete(streamId);
|
|
596
|
+
const suggestions = ['继续完善这份结果', '换一种呈现方式', '告诉我下一步怎么做'];
|
|
597
|
+
for (const listener of suggestedReplyListeners) {
|
|
598
|
+
listener({ chatId, messageId: assistantMessage.id, suggestions });
|
|
599
|
+
}
|
|
591
600
|
}, 440 + chunks.length * 80);
|
|
592
601
|
|
|
593
602
|
return streamId;
|
|
@@ -629,6 +638,12 @@ export function installBrowserDevElectronMock() {
|
|
|
629
638
|
onMenuOpenTerminal: () => undefined,
|
|
630
639
|
offMenuOpenTerminal: () => undefined,
|
|
631
640
|
onChatTitleUpdated: () => () => undefined,
|
|
641
|
+
onSuggestedReplies: (callback) => {
|
|
642
|
+
suggestedReplyListeners.add(callback);
|
|
643
|
+
return () => {
|
|
644
|
+
suggestedReplyListeners.delete(callback);
|
|
645
|
+
};
|
|
646
|
+
},
|
|
632
647
|
} satisfies ElectronBridge;
|
|
633
648
|
|
|
634
649
|
console.info(
|
|
@@ -153,6 +153,17 @@ export interface ElectronBridge {
|
|
|
153
153
|
onChatTitleUpdated?: (
|
|
154
154
|
callback: (payload: { chatId: string; title: string }) => void,
|
|
155
155
|
) => () => void;
|
|
156
|
+
/**
|
|
157
|
+
* 回合追问建议就绪。Backend 在助手回复完成后推 `{chatId, messageId, suggestions}`
|
|
158
|
+
* (先启发式、后 LLM 替换)。返回解除订阅函数。
|
|
159
|
+
*/
|
|
160
|
+
onSuggestedReplies?: (
|
|
161
|
+
callback: (payload: {
|
|
162
|
+
chatId: string;
|
|
163
|
+
messageId: string;
|
|
164
|
+
suggestions: string[];
|
|
165
|
+
}) => void,
|
|
166
|
+
) => () => void;
|
|
156
167
|
/**
|
|
157
168
|
* 会话补建通知。Backend 在「向一个本地不存在的 chatId 首次发送」时按 URL
|
|
158
169
|
* 里的 id 现场补建会话(见 router.handleStream),随后广播 `chat-created`
|
|
@@ -202,6 +213,7 @@ export interface ElectronBridge {
|
|
|
202
213
|
kind: ApprovalDecisionKind;
|
|
203
214
|
reason?: string;
|
|
204
215
|
}) => Promise<void>;
|
|
216
|
+
pending: () => Promise<ApprovalPromptRequest[]>;
|
|
205
217
|
};
|
|
206
218
|
/**
|
|
207
219
|
* W8 结构化提问:sidecar ask_user 工具的请示经主进程/BS 服务器广播到
|
|
@@ -214,6 +226,7 @@ export interface ElectronBridge {
|
|
|
214
226
|
requestId: string;
|
|
215
227
|
answers: Record<string, string | string[]>;
|
|
216
228
|
}) => Promise<void>;
|
|
229
|
+
pending: () => Promise<AskUserPromptRequest[]>;
|
|
217
230
|
};
|
|
218
231
|
terminal?: {
|
|
219
232
|
/** Ensures a session exists (reuses last one if alive). */
|
|
@@ -230,13 +243,6 @@ export interface ElectronBridge {
|
|
|
230
243
|
signal: string | null;
|
|
231
244
|
}) => void,
|
|
232
245
|
) => () => 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
246
|
};
|
|
241
247
|
// 场景包的 invoke 命名空间(如包 preload 贡献的 `<pack>` / `<pack>Mock`)
|
|
242
248
|
// 不进本接口——包用自己的结构化收窄访问器(见各包 web/bridge.ts),
|
package/src/lib/http-bridge.ts
CHANGED
|
@@ -241,6 +241,12 @@ export function createHttpBridge(): ElectronBridge {
|
|
|
241
241
|
onChatTitleUpdated: (callback) =>
|
|
242
242
|
subscribeChannel<{ chatId: string; title: string }>('chat-title-updated', callback),
|
|
243
243
|
|
|
244
|
+
onSuggestedReplies: (callback) =>
|
|
245
|
+
subscribeChannel<{ chatId: string; messageId: string; suggestions: string[] }>(
|
|
246
|
+
'suggested-replies',
|
|
247
|
+
callback,
|
|
248
|
+
),
|
|
249
|
+
|
|
244
250
|
onChatCreated: (callback) =>
|
|
245
251
|
subscribeChannel<{ chatId: string; agentId?: string | null }>('chat-created', callback),
|
|
246
252
|
|
|
@@ -268,6 +274,7 @@ export function createHttpBridge(): ElectronBridge {
|
|
|
268
274
|
decide: async (decision) => {
|
|
269
275
|
await http('POST', '/host/approval/decide', decision);
|
|
270
276
|
},
|
|
277
|
+
pending: () => http<ApprovalPromptRequest[]>('GET', '/host/approval/pending'),
|
|
271
278
|
},
|
|
272
279
|
|
|
273
280
|
askUser: {
|
|
@@ -276,6 +283,7 @@ export function createHttpBridge(): ElectronBridge {
|
|
|
276
283
|
answer: async (reply) => {
|
|
277
284
|
await http('POST', '/host/ask-user/answer', reply);
|
|
278
285
|
},
|
|
286
|
+
pending: () => http<AskUserPromptRequest[]>('GET', '/host/ask-user/pending'),
|
|
279
287
|
},
|
|
280
288
|
|
|
281
289
|
terminal: {
|
|
@@ -289,8 +297,6 @@ export function createHttpBridge(): ElectronBridge {
|
|
|
289
297
|
'terminal:exit',
|
|
290
298
|
callback,
|
|
291
299
|
),
|
|
292
|
-
onReveal: (callback) =>
|
|
293
|
-
subscribeChannel<{ sessionId: string }>('terminal:reveal', callback),
|
|
294
300
|
},
|
|
295
301
|
|
|
296
302
|
};
|
package/src/lib/local-api.ts
CHANGED
|
@@ -332,6 +332,37 @@ export async function cancelChatTurn(chatId: string) {
|
|
|
332
332
|
});
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* 用系统默认应用打开本地绝对路径(回合产物列表的点击行为)。
|
|
337
|
+
* 成功时后端返回 `{ success: true }`;打开失败返回 `{ success: false, error }`。
|
|
338
|
+
*/
|
|
339
|
+
export async function openLocalPath(targetPath: string) {
|
|
340
|
+
return bridge().localBackend.request<{ success: boolean; error?: string }>({
|
|
341
|
+
method: 'POST',
|
|
342
|
+
path: '/api/v2/local/open-path',
|
|
343
|
+
body: { path: targetPath },
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 后端确认存在的路径:`candidate` 是正文里的原字面量,`path` 是落地绝对路径。 */
|
|
348
|
+
export interface ResolvedLocalPath {
|
|
349
|
+
candidate: string;
|
|
350
|
+
path: string;
|
|
351
|
+
isDirectory: boolean;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 批量确认正文里提到的路径是否真实存在(用于决定行内代码要不要变成可点击)。
|
|
356
|
+
* 相对路径由后端按对话绑定的项目根解析,无项目时按 home;不存在的候选不回。
|
|
357
|
+
*/
|
|
358
|
+
export async function resolveLocalPaths(candidates: string[], chatId?: string | null) {
|
|
359
|
+
return bridge().localBackend.request<{ resolved: ResolvedLocalPath[] }>({
|
|
360
|
+
method: 'POST',
|
|
361
|
+
path: '/api/v2/local/resolve-paths',
|
|
362
|
+
body: { candidates, ...(chatId ? { chatId } : {}) },
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
335
366
|
/* ---------------- Projects(项目模式) ---------------- */
|
|
336
367
|
|
|
337
368
|
export interface LocalProject {
|