@steerable/agent-shell-web 0.6.62 → 0.6.64
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/AgentSidebar.tsx +1 -0
- package/src/components/ConfirmDialog.tsx +41 -6
- package/src/components/TerminalView.tsx +10 -1
- package/src/components/chat/ChatInput.tsx +15 -10
- package/src/components/chat/GoalLoopStatusBar.tsx +122 -0
- package/src/components/chat/LocalChatPanel.tsx +4 -0
- package/src/components/chat/MessageList.tsx +41 -16
- package/src/components/chat/useGoalAndLoops.ts +71 -0
- package/src/lib/http-bridge.ts +26 -6
- package/src/lib/local-api.ts +54 -0
- package/src/pages/AgentPage.tsx +66 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.64",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"react-router-dom": "^6.30.0",
|
|
46
46
|
"rehype-raw": "^7.0.0",
|
|
47
47
|
"remark-gfm": "^4.0.1",
|
|
48
|
-
"@steerable/agent-protocol": "0.6.
|
|
49
|
-
"@steerable/agent-ui": "0.6.
|
|
48
|
+
"@steerable/agent-protocol": "0.6.64",
|
|
49
|
+
"@steerable/agent-ui": "0.6.64"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@tailwindcss/vite": "^4.3.0",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"typescript": "^5.8.3",
|
|
60
60
|
"vite": "^5.4.21",
|
|
61
61
|
"vitest": "^3.2.4",
|
|
62
|
-
"@steerable/pack-sdk": "0.6.
|
|
62
|
+
"@steerable/pack-sdk": "0.6.64"
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
65
|
"gen": "echo agent-shell-web",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 破坏性操作的二次确认弹窗。点遮罩或按 Esc 取消;确认进行中不关闭。
|
|
3
3
|
*/
|
|
4
|
-
import { useEffect, useId } from 'react';
|
|
4
|
+
import { useEffect, useId, useRef } from 'react';
|
|
5
5
|
import { createPortal } from 'react-dom';
|
|
6
6
|
import { t } from '@/i18n';
|
|
7
7
|
|
|
@@ -9,6 +9,7 @@ export interface ConfirmDialogProps {
|
|
|
9
9
|
open: boolean;
|
|
10
10
|
title: string;
|
|
11
11
|
description: string;
|
|
12
|
+
error?: string | null;
|
|
12
13
|
confirmLabel?: string;
|
|
13
14
|
pending?: boolean;
|
|
14
15
|
onCancel: () => void;
|
|
@@ -20,6 +21,7 @@ export function ConfirmDialog({
|
|
|
20
21
|
open,
|
|
21
22
|
title,
|
|
22
23
|
description,
|
|
24
|
+
error,
|
|
23
25
|
confirmLabel = t('Delete'),
|
|
24
26
|
pending = false,
|
|
25
27
|
onCancel,
|
|
@@ -28,21 +30,49 @@ export function ConfirmDialog({
|
|
|
28
30
|
}: ConfirmDialogProps) {
|
|
29
31
|
const titleId = useId();
|
|
30
32
|
const descriptionId = useId();
|
|
33
|
+
const errorId = useId();
|
|
34
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
31
36
|
|
|
32
37
|
useEffect(() => {
|
|
33
38
|
if (!open) return;
|
|
39
|
+
const previouslyFocused =
|
|
40
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
41
|
+
cancelRef.current?.focus();
|
|
34
42
|
const onKey = (event: KeyboardEvent) => {
|
|
35
|
-
if (event.key
|
|
36
|
-
|
|
43
|
+
if (event.key === 'Escape' && !pending) {
|
|
44
|
+
onCancel();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (event.key !== 'Tab') return;
|
|
48
|
+
const focusable = Array.from(
|
|
49
|
+
dialogRef.current?.querySelectorAll<HTMLElement>(
|
|
50
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
51
|
+
) ?? [],
|
|
52
|
+
);
|
|
53
|
+
if (focusable.length === 0) return;
|
|
54
|
+
const first = focusable[0];
|
|
55
|
+
const last = focusable[focusable.length - 1];
|
|
56
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
last.focus();
|
|
59
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
first.focus();
|
|
62
|
+
}
|
|
37
63
|
};
|
|
38
64
|
window.addEventListener('keydown', onKey);
|
|
39
|
-
return () =>
|
|
65
|
+
return () => {
|
|
66
|
+
window.removeEventListener('keydown', onKey);
|
|
67
|
+
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
|
68
|
+
};
|
|
40
69
|
}, [open, pending, onCancel]);
|
|
41
70
|
|
|
42
71
|
if (!open || typeof document === 'undefined') return null;
|
|
43
72
|
|
|
44
73
|
return createPortal(
|
|
45
74
|
<div
|
|
75
|
+
ref={dialogRef}
|
|
46
76
|
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
|
47
77
|
onMouseDown={(event) => {
|
|
48
78
|
if (event.target === event.currentTarget && !pending) onCancel();
|
|
@@ -50,7 +80,7 @@ export function ConfirmDialog({
|
|
|
50
80
|
role="alertdialog"
|
|
51
81
|
aria-modal="true"
|
|
52
82
|
aria-labelledby={titleId}
|
|
53
|
-
aria-describedby={descriptionId}
|
|
83
|
+
aria-describedby={error ? `${descriptionId} ${errorId}` : descriptionId}
|
|
54
84
|
data-testid={testId}
|
|
55
85
|
>
|
|
56
86
|
<div className="w-[380px] max-w-[92vw] rounded-2xl border border-agent-border bg-agent-canvas p-5 shadow-2xl">
|
|
@@ -63,12 +93,17 @@ export function ConfirmDialog({
|
|
|
63
93
|
>
|
|
64
94
|
{description}
|
|
65
95
|
</p>
|
|
96
|
+
{error && (
|
|
97
|
+
<p id={errorId} role="alert" className="mt-2 text-sm text-agent-destructive">
|
|
98
|
+
{error}
|
|
99
|
+
</p>
|
|
100
|
+
)}
|
|
66
101
|
<div className="mt-5 flex justify-end gap-2">
|
|
67
102
|
<button
|
|
103
|
+
ref={cancelRef}
|
|
68
104
|
type="button"
|
|
69
105
|
onClick={onCancel}
|
|
70
106
|
disabled={pending}
|
|
71
|
-
autoFocus
|
|
72
107
|
className="h-9 rounded-full px-4 text-sm text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground disabled:opacity-40"
|
|
73
108
|
data-testid={`${testId}-cancel`}
|
|
74
109
|
>
|
|
@@ -139,8 +139,11 @@ export function TerminalView({
|
|
|
139
139
|
containerRef.current.addEventListener('contextmenu', onContextMenu);
|
|
140
140
|
|
|
141
141
|
let cancelled = false;
|
|
142
|
+
const pendingChunks: Array<{ sessionId: string; chunk: string }> = [];
|
|
142
143
|
const offData = terminal.onData(({ sessionId, chunk }) => {
|
|
143
|
-
if (sessionIdRef.current
|
|
144
|
+
if (sessionIdRef.current === null) {
|
|
145
|
+
pendingChunks.push({ sessionId, chunk });
|
|
146
|
+
} else if (sessionId === sessionIdRef.current) {
|
|
144
147
|
term.write(chunk);
|
|
145
148
|
}
|
|
146
149
|
});
|
|
@@ -159,6 +162,12 @@ export function TerminalView({
|
|
|
159
162
|
});
|
|
160
163
|
if (cancelled) return;
|
|
161
164
|
sessionIdRef.current = session.id;
|
|
165
|
+
for (const pending of pendingChunks) {
|
|
166
|
+
if (pending.sessionId === session.id) {
|
|
167
|
+
term.write(pending.chunk);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
pendingChunks.length = 0;
|
|
162
171
|
setStatus('ready');
|
|
163
172
|
term.writeln(
|
|
164
173
|
`\x1b[90m[agent-shell] ${session.shell} pid=${session.pid} cwd=${session.cwd}\x1b[0m`,
|
|
@@ -1254,11 +1254,13 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1254
1254
|
setIsDragging(false);
|
|
1255
1255
|
};
|
|
1256
1256
|
|
|
1257
|
-
//
|
|
1257
|
+
// 桌面壳的真实 path 可去重;浏览器 File 没有 path,同名文件可能来自
|
|
1258
|
+
// 不同目录,必须全部保留,提交落盘时由宿主生成唯一文件名。
|
|
1258
1259
|
const addFiles = (incoming: AttachmentFile[]) => {
|
|
1259
|
-
const
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1260
|
+
const existingPaths = new Set(actualFiles.map((file) => file.path).filter(Boolean));
|
|
1261
|
+
const filteredNewFiles = incoming.filter(
|
|
1262
|
+
(file) => !file.path || !existingPaths.has(file.path),
|
|
1263
|
+
);
|
|
1262
1264
|
if (filteredNewFiles.length > 0) {
|
|
1263
1265
|
actualOnFilesChange([...actualFiles, ...filteredNewFiles]);
|
|
1264
1266
|
}
|
|
@@ -1349,9 +1351,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1349
1351
|
if (value[removeEnd] === ' ') removeEnd += 1;
|
|
1350
1352
|
replaceRange(start, removeEnd, '');
|
|
1351
1353
|
if (ref) {
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
+
const nextReferences = mentionReferences.filter(
|
|
1355
|
+
(item) => !(item.type === ref.type && item.id === ref.id),
|
|
1354
1356
|
);
|
|
1357
|
+
setMentionReferences(nextReferences);
|
|
1358
|
+
onMentionReferencesChange?.(nextReferences);
|
|
1355
1359
|
}
|
|
1356
1360
|
};
|
|
1357
1361
|
|
|
@@ -1686,12 +1690,13 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1686
1690
|
event.key === 'Enter' && !event.shiftKey;
|
|
1687
1691
|
if (isSendCombo) {
|
|
1688
1692
|
event.preventDefault();
|
|
1693
|
+
const submittedText = getEditableText(event.currentTarget).trim();
|
|
1689
1694
|
// streaming 期间:Enter = follow-up 排队(本轮结束后自动作为下一轮
|
|
1690
1695
|
// 发出);⌘/Ctrl+Enter = 轮中插队(注入运行中的回合;注入不了时
|
|
1691
1696
|
// hook 兜底为 W6-2 排队或新回合直发,消息不会丢)。
|
|
1692
1697
|
if (isStreaming) {
|
|
1693
|
-
if ((event.metaKey || event.ctrlKey) && onSteer &&
|
|
1694
|
-
void onSteer(
|
|
1698
|
+
if ((event.metaKey || event.ctrlKey) && onSteer && submittedText) {
|
|
1699
|
+
void onSteer(submittedText).then((outcome) => {
|
|
1695
1700
|
// steered / queued / sent 三种结果消息都已落地(注入当前回合 /
|
|
1696
1701
|
// 进入待发队列 / 作为新回合发出),草稿都可以清;queued 额外
|
|
1697
1702
|
// 提示用户"不是追加进当前回合"。
|
|
@@ -1706,8 +1711,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1706
1711
|
});
|
|
1707
1712
|
return;
|
|
1708
1713
|
}
|
|
1709
|
-
if (onFollowUp &&
|
|
1710
|
-
onFollowUp(
|
|
1714
|
+
if (onFollowUp && submittedText) {
|
|
1715
|
+
onFollowUp(submittedText);
|
|
1711
1716
|
onChange('');
|
|
1712
1717
|
}
|
|
1713
1718
|
return;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { LuCirclePause, LuPlay, LuRepeat2, LuSquare, LuTarget, LuTrash2 } from 'react-icons/lu';
|
|
3
|
+
|
|
4
|
+
import { t } from '@/i18n';
|
|
5
|
+
import {
|
|
6
|
+
stopChatLoop,
|
|
7
|
+
updateChatGoal,
|
|
8
|
+
type LocalGoal,
|
|
9
|
+
type LocalMonitoredLoop,
|
|
10
|
+
} from '@/lib/local-api';
|
|
11
|
+
|
|
12
|
+
export interface GoalLoopStatusBarProps {
|
|
13
|
+
chatId: string;
|
|
14
|
+
goal: LocalGoal | null;
|
|
15
|
+
loops: LocalMonitoredLoop[];
|
|
16
|
+
onChanged: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Compact native status for the goal and monitored PTY loops in this chat. */
|
|
20
|
+
export function GoalLoopStatusBar({
|
|
21
|
+
chatId,
|
|
22
|
+
goal,
|
|
23
|
+
loops = [],
|
|
24
|
+
onChanged,
|
|
25
|
+
}: GoalLoopStatusBarProps) {
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
const visibleGoal = goal?.phase === 'complete' ? null : goal;
|
|
28
|
+
if (!visibleGoal && loops.length === 0) return null;
|
|
29
|
+
|
|
30
|
+
const goalAction = async (action: 'pause' | 'resume' | 'clear') => {
|
|
31
|
+
setBusy(true);
|
|
32
|
+
try {
|
|
33
|
+
await updateChatGoal(chatId, { action });
|
|
34
|
+
onChanged();
|
|
35
|
+
} finally {
|
|
36
|
+
setBusy(false);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const stopLoop = async (id: string) => {
|
|
41
|
+
setBusy(true);
|
|
42
|
+
try {
|
|
43
|
+
await stopChatLoop(chatId, id);
|
|
44
|
+
onChanged();
|
|
45
|
+
} finally {
|
|
46
|
+
setBusy(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="mx-2.5 mb-1 space-y-1 rounded-agent-md border border-agent-border bg-agent-muted/30 px-2.5 py-1.5 text-xs">
|
|
52
|
+
{visibleGoal ? (
|
|
53
|
+
<div className="flex items-center gap-2">
|
|
54
|
+
<LuTarget className="h-3.5 w-3.5 shrink-0" />
|
|
55
|
+
<span className="min-w-0 flex-1 truncate">
|
|
56
|
+
{t('Goal')} · {visibleGoal.objective} · {goalPhase(visibleGoal)}
|
|
57
|
+
</span>
|
|
58
|
+
{visibleGoal.phase === 'active' ? (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
disabled={busy}
|
|
62
|
+
onClick={() => void goalAction('pause')}
|
|
63
|
+
title={t('Pause goal')}
|
|
64
|
+
aria-label={t('Pause goal')}
|
|
65
|
+
>
|
|
66
|
+
<LuCirclePause className="h-3.5 w-3.5" />
|
|
67
|
+
</button>
|
|
68
|
+
) : visibleGoal.phase === 'paused' || visibleGoal.phase === 'blocked' ? (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
disabled={busy}
|
|
72
|
+
onClick={() => void goalAction('resume')}
|
|
73
|
+
title={t('Resume goal')}
|
|
74
|
+
aria-label={t('Resume goal')}
|
|
75
|
+
>
|
|
76
|
+
<LuPlay className="h-3.5 w-3.5" />
|
|
77
|
+
</button>
|
|
78
|
+
) : null}
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
disabled={busy}
|
|
82
|
+
onClick={() => void goalAction('clear')}
|
|
83
|
+
title={t('Clear goal')}
|
|
84
|
+
aria-label={t('Clear goal')}
|
|
85
|
+
>
|
|
86
|
+
<LuTrash2 className="h-3.5 w-3.5" />
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
{loops.map((loop) => (
|
|
91
|
+
<div key={loop.id} className="flex items-center gap-2">
|
|
92
|
+
<LuRepeat2 className="h-3.5 w-3.5 shrink-0" />
|
|
93
|
+
<span className="min-w-0 flex-1 truncate">
|
|
94
|
+
{t('Loop')} · {loop.prompt} · {t('every {seconds}s', { seconds: loop.intervalSeconds })}
|
|
95
|
+
</span>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
disabled={busy}
|
|
99
|
+
onClick={() => void stopLoop(loop.id)}
|
|
100
|
+
title={t('Stop loop')}
|
|
101
|
+
aria-label={t('Stop loop')}
|
|
102
|
+
className="flex shrink-0 items-center gap-1 rounded-agent-sm border border-agent-border bg-agent-canva px-1.5 py-0.5 font-medium hover:bg-agent-muted disabled:opacity-50"
|
|
103
|
+
>
|
|
104
|
+
<LuSquare className="h-2.5 w-2.5 fill-current" />
|
|
105
|
+
<span>{t('Stop')}</span>
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function goalPhase(goal: LocalGoal): string {
|
|
114
|
+
if (goal.phase === 'active') return t('Turn {turn}', { turn: goal.turns });
|
|
115
|
+
if (goal.phase === 'paused') return t('Paused');
|
|
116
|
+
if (goal.phase === 'blocked') {
|
|
117
|
+
return goal.blockedReason
|
|
118
|
+
? t('Blocked: {reason}', { reason: goal.blockedReason })
|
|
119
|
+
: t('Blocked');
|
|
120
|
+
}
|
|
121
|
+
return t('Complete');
|
|
122
|
+
}
|
|
@@ -225,6 +225,10 @@ export function LocalChatPanel({
|
|
|
225
225
|
const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
|
|
226
226
|
const inputRef = useRef<ChatInputHandle>(null);
|
|
227
227
|
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
inputRef.current?.focusAtEnd();
|
|
230
|
+
}, [chatId]);
|
|
231
|
+
|
|
228
232
|
const handleSelectPrompt = useCallback((prompt: string) => {
|
|
229
233
|
setInputValue(prompt);
|
|
230
234
|
// Wait for the textarea to receive the new value before pulling focus,
|
|
@@ -54,6 +54,22 @@ import type { LlmSpeedSnapshot } from './process-status';
|
|
|
54
54
|
|
|
55
55
|
const NEAR_BOTTOM_THRESHOLD_PX = 100;
|
|
56
56
|
|
|
57
|
+
function internalTrigger(message: ChatMessage): 'goal' | 'loop' | null {
|
|
58
|
+
if (!message.messageMetadata) return null;
|
|
59
|
+
try {
|
|
60
|
+
const metadata = JSON.parse(message.messageMetadata) as {
|
|
61
|
+
internal?: unknown;
|
|
62
|
+
trigger?: unknown;
|
|
63
|
+
};
|
|
64
|
+
if (metadata.internal !== true) return null;
|
|
65
|
+
return metadata.trigger === 'goal' || metadata.trigger === 'loop'
|
|
66
|
+
? metadata.trigger
|
|
67
|
+
: null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
interface MessageListProps {
|
|
58
74
|
messages: ChatMessage[];
|
|
59
75
|
isStreaming: boolean;
|
|
@@ -330,15 +346,20 @@ export function MessageList({
|
|
|
330
346
|
className="conversation-turn relative space-y-4"
|
|
331
347
|
data-conversation-turn={turn.key}
|
|
332
348
|
>
|
|
333
|
-
{turn.userGroup &&
|
|
349
|
+
{turn.userGroup &&
|
|
350
|
+
!turn.userGroup.messages.every((message) => internalTrigger(message) !== null) ? (
|
|
334
351
|
<div className="sticky top-0 z-10 -mx-3 px-3 py-1 bg-agent-canvas/95 backdrop-blur-xs">
|
|
335
352
|
{turn.userGroup.messages.length === 1 ? (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
353
|
+
internalTrigger(turn.userGroup.messages[0]) ? (
|
|
354
|
+
null
|
|
355
|
+
) : (
|
|
356
|
+
<UserMessage
|
|
357
|
+
key={turn.userGroup.messages[0].id}
|
|
358
|
+
message={turn.userGroup.messages[0]}
|
|
359
|
+
agents={agents}
|
|
360
|
+
chats={chats}
|
|
361
|
+
/>
|
|
362
|
+
)
|
|
342
363
|
) : (
|
|
343
364
|
<div
|
|
344
365
|
key={turn.userGroup.key}
|
|
@@ -348,21 +369,25 @@ export function MessageList({
|
|
|
348
369
|
<div className="flex justify-start">
|
|
349
370
|
<div className="mx-auto w-full max-w-[var(--chat-input-box-width)] overflow-hidden rounded-agent-lg border border-agent-border/80 bg-agent-muted/70 shadow-sm">
|
|
350
371
|
{turn.userGroup.messages.map((msg, idx) => (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
372
|
+
internalTrigger(msg) ? (
|
|
373
|
+
null
|
|
374
|
+
) : (
|
|
375
|
+
<UserMessage
|
|
376
|
+
key={msg.id}
|
|
377
|
+
message={msg}
|
|
378
|
+
agents={agents}
|
|
379
|
+
chats={chats}
|
|
380
|
+
isGrouped
|
|
381
|
+
isAppended={idx > 0}
|
|
382
|
+
/>
|
|
383
|
+
)
|
|
359
384
|
))}
|
|
360
385
|
</div>
|
|
361
386
|
</div>
|
|
362
387
|
</div>
|
|
363
388
|
)}
|
|
364
389
|
</div>
|
|
365
|
-
)}
|
|
390
|
+
) : null}
|
|
366
391
|
|
|
367
392
|
{turn.assistants.map((item) => {
|
|
368
393
|
const message = item.message;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getChatGoal,
|
|
5
|
+
listChatLoops,
|
|
6
|
+
type LocalGoal,
|
|
7
|
+
type LocalMonitoredLoop,
|
|
8
|
+
} from '@/lib/local-api';
|
|
9
|
+
import { getHostBridge, hasHostBridge } from '@/lib/host-bridge';
|
|
10
|
+
|
|
11
|
+
export interface GoalAndLoopsState {
|
|
12
|
+
goal: LocalGoal | null;
|
|
13
|
+
loops: LocalMonitoredLoop[];
|
|
14
|
+
refreshGoal: () => Promise<void>;
|
|
15
|
+
refreshLoops: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Native goal state and Cursor-style monitored-loop process state for one chat. */
|
|
19
|
+
export function useGoalAndLoops(chatId: string | null): GoalAndLoopsState {
|
|
20
|
+
const [goal, setGoal] = useState<LocalGoal | null>(null);
|
|
21
|
+
const [loops, setLoops] = useState<LocalMonitoredLoop[]>([]);
|
|
22
|
+
|
|
23
|
+
const refreshGoal = useCallback(async () => {
|
|
24
|
+
if (!chatId || !hasHostBridge()) {
|
|
25
|
+
setGoal(null);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
setGoal((await getChatGoal(chatId)).goal);
|
|
30
|
+
} catch {
|
|
31
|
+
setGoal(null);
|
|
32
|
+
}
|
|
33
|
+
}, [chatId]);
|
|
34
|
+
|
|
35
|
+
const refreshLoops = useCallback(async () => {
|
|
36
|
+
if (!chatId || !hasHostBridge()) {
|
|
37
|
+
setLoops([]);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const response = await listChatLoops(chatId);
|
|
42
|
+
setLoops(Array.isArray(response.loops) ? response.loops : []);
|
|
43
|
+
} catch {
|
|
44
|
+
setLoops([]);
|
|
45
|
+
}
|
|
46
|
+
}, [chatId]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
void refreshGoal();
|
|
50
|
+
void refreshLoops();
|
|
51
|
+
}, [refreshGoal, refreshLoops]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!chatId) return;
|
|
55
|
+
const bridge = getHostBridge();
|
|
56
|
+
const offGoal = bridge?.onPackEvent?.('goal-changed', (value) => {
|
|
57
|
+
const payload = value as { chatId?: string; goal?: LocalGoal | null };
|
|
58
|
+
if (payload.chatId === chatId) setGoal(payload.goal ?? null);
|
|
59
|
+
});
|
|
60
|
+
const offLoop = bridge?.onPackEvent?.('loop-changed', (value) => {
|
|
61
|
+
const payload = value as { chatId?: string; loops?: LocalMonitoredLoop[] };
|
|
62
|
+
if (payload.chatId === chatId) setLoops(payload.loops ?? []);
|
|
63
|
+
});
|
|
64
|
+
return () => {
|
|
65
|
+
offGoal?.();
|
|
66
|
+
offLoop?.();
|
|
67
|
+
};
|
|
68
|
+
}, [chatId]);
|
|
69
|
+
|
|
70
|
+
return { goal, loops, refreshGoal, refreshLoops };
|
|
71
|
+
}
|
package/src/lib/http-bridge.ts
CHANGED
|
@@ -119,10 +119,18 @@ function subscribeChannel<T>(channel: string, callback: (payload: T) => void): (
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
// ---------------------------------------------------------------------------
|
|
122
|
-
// 流式聊天:fetch + ReadableStream
|
|
123
|
-
//
|
|
122
|
+
// 流式聊天:fetch + ReadableStream。聊天流通过显式 cancel 端点停止,原
|
|
123
|
+
// response 在服务端完成取消和落库后自然结束;其他流回退 AbortController。
|
|
124
124
|
// ---------------------------------------------------------------------------
|
|
125
|
-
const activeStreams = new Map<
|
|
125
|
+
const activeStreams = new Map<
|
|
126
|
+
string,
|
|
127
|
+
{ controller: AbortController; cancelPath: string | null; cancelling: boolean }
|
|
128
|
+
>();
|
|
129
|
+
|
|
130
|
+
function chatCancelPath(path: string): string | null {
|
|
131
|
+
const match = path.match(/^(\/api\/v2\/chats\/[^/]+)\/(?:send|run|agent)$/);
|
|
132
|
+
return match ? `${match[1]}/cancel` : null;
|
|
133
|
+
}
|
|
126
134
|
|
|
127
135
|
async function startStream(
|
|
128
136
|
input: LocalBackendRequestInput,
|
|
@@ -146,7 +154,11 @@ async function startStream(
|
|
|
146
154
|
onEvent({ type: 'error', error: `start stream failed (${res.status})` });
|
|
147
155
|
return null;
|
|
148
156
|
}
|
|
149
|
-
activeStreams.set(streamId,
|
|
157
|
+
activeStreams.set(streamId, {
|
|
158
|
+
controller,
|
|
159
|
+
cancelPath: chatCancelPath(input.path),
|
|
160
|
+
cancelling: false,
|
|
161
|
+
});
|
|
150
162
|
void (async () => {
|
|
151
163
|
const reader = res.body!.getReader();
|
|
152
164
|
const decoder = new TextDecoder();
|
|
@@ -172,8 +184,16 @@ async function startStream(
|
|
|
172
184
|
}
|
|
173
185
|
|
|
174
186
|
function cancelStream(streamId: string): void {
|
|
175
|
-
activeStreams.get(streamId)
|
|
176
|
-
|
|
187
|
+
const active = activeStreams.get(streamId);
|
|
188
|
+
if (!active || active.cancelling) return;
|
|
189
|
+
active.cancelling = true;
|
|
190
|
+
if (!active.cancelPath) {
|
|
191
|
+
active.controller.abort();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
void http('POST', active.cancelPath, {}).catch(() => {
|
|
195
|
+
active.controller.abort();
|
|
196
|
+
});
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
// ---------------------------------------------------------------------------
|
package/src/lib/local-api.ts
CHANGED
|
@@ -341,6 +341,60 @@ export async function getChatLiveStream(chatId: string) {
|
|
|
341
341
|
});
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
export interface LocalGoal {
|
|
345
|
+
id: string;
|
|
346
|
+
chatId: string;
|
|
347
|
+
revision: number;
|
|
348
|
+
objective: string;
|
|
349
|
+
phase: 'active' | 'paused' | 'blocked' | 'complete';
|
|
350
|
+
blockedReason?: string;
|
|
351
|
+
turns: number;
|
|
352
|
+
createdAt: number;
|
|
353
|
+
updatedAt: number;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function getChatGoal(chatId: string) {
|
|
357
|
+
return bridge().localBackend.request<{ goal: LocalGoal | null }>({
|
|
358
|
+
method: 'GET',
|
|
359
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/goal`,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function updateChatGoal(
|
|
364
|
+
chatId: string,
|
|
365
|
+
body:
|
|
366
|
+
| { action: 'edit'; objective: string }
|
|
367
|
+
| { action: 'pause' | 'resume' | 'complete' | 'clear' },
|
|
368
|
+
) {
|
|
369
|
+
return bridge().localBackend.request<{ goal: LocalGoal | null }>({
|
|
370
|
+
method: 'POST',
|
|
371
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/goal`,
|
|
372
|
+
body,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface LocalMonitoredLoop {
|
|
377
|
+
id: string;
|
|
378
|
+
chatId: string;
|
|
379
|
+
terminalSessionId: string;
|
|
380
|
+
prompt: string;
|
|
381
|
+
intervalSeconds: number;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function listChatLoops(chatId: string) {
|
|
385
|
+
return bridge().localBackend.request<{ loops: LocalMonitoredLoop[] }>({
|
|
386
|
+
method: 'GET',
|
|
387
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/loops`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export async function stopChatLoop(chatId: string, loopId: string) {
|
|
392
|
+
return bridge().localBackend.request<{ success: boolean }>({
|
|
393
|
+
method: 'DELETE',
|
|
394
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/loops/${encodeURIComponent(loopId)}`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
344
398
|
/** 按 chatId 取消运行中的回合(切回后新挂载的视图手里没有原 streamId)。 */
|
|
345
399
|
export async function cancelChatTurn(chatId: string) {
|
|
346
400
|
return bridge().localBackend.request<{ success: boolean; reason?: string }>({
|
package/src/pages/AgentPage.tsx
CHANGED
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
type SuggestedRepliesState,
|
|
44
44
|
} from "@/components/chat/suggested-replies-model";
|
|
45
45
|
import { useChatTasks } from "@/components/chat/useChatTasks";
|
|
46
|
+
import { useGoalAndLoops } from "@/components/chat/useGoalAndLoops";
|
|
47
|
+
import { GoalLoopStatusBar } from "@/components/chat/GoalLoopStatusBar";
|
|
46
48
|
import { LuListChecks, LuArrowRight } from "react-icons/lu";
|
|
47
49
|
import { LocalLlmSettingsModal } from "@/components/LocalLlmSettingsModal";
|
|
48
50
|
import {
|
|
@@ -398,6 +400,12 @@ function AgentChatView({
|
|
|
398
400
|
finished: finishedTasks,
|
|
399
401
|
dismissFinished,
|
|
400
402
|
} = useChatTasks(chatId);
|
|
403
|
+
const {
|
|
404
|
+
goal,
|
|
405
|
+
loops,
|
|
406
|
+
refreshGoal,
|
|
407
|
+
refreshLoops,
|
|
408
|
+
} = useGoalAndLoops(chatId);
|
|
401
409
|
const handleInspectTask = useCallback(
|
|
402
410
|
(task: InspectTaskInput) => {
|
|
403
411
|
ctx.inspectTask({
|
|
@@ -416,6 +424,7 @@ function AgentChatView({
|
|
|
416
424
|
// 把「正在运行 + 部分产出」恢复出来。initialLiveStream 是挂载时同步拿到的
|
|
417
425
|
// 首帧,之后由轮询持续刷新。
|
|
418
426
|
const [liveStream, setLiveStream] = useState<ChatLiveStream>(initialLiveStream);
|
|
427
|
+
const liveStreamEpochRef = useRef(0);
|
|
419
428
|
|
|
420
429
|
// ── executed_actions plumbing ────────────────────────────────────────
|
|
421
430
|
// Local-backend emits `{type: 'executed_actions', actions: [...]}` AFTER each
|
|
@@ -683,7 +692,6 @@ function AgentChatView({
|
|
|
683
692
|
followUpUserMessage,
|
|
684
693
|
pendingFollowUps,
|
|
685
694
|
removeFollowUp,
|
|
686
|
-
cancel,
|
|
687
695
|
setMessages,
|
|
688
696
|
appendMessage,
|
|
689
697
|
} = useChatStream({
|
|
@@ -692,6 +700,42 @@ function AgentChatView({
|
|
|
692
700
|
onUnknownEvent: handleUnknownEvent,
|
|
693
701
|
onStreamError: handleStreamError,
|
|
694
702
|
});
|
|
703
|
+
const awaitingRemoteActiveSnapshotRef = useRef(false);
|
|
704
|
+
useEffect(() => {
|
|
705
|
+
awaitingRemoteActiveSnapshotRef.current = false;
|
|
706
|
+
}, [chatId]);
|
|
707
|
+
|
|
708
|
+
useEffect(() => {
|
|
709
|
+
const bridge = getHostBridge();
|
|
710
|
+
if (!bridge?.onPackEvent) return;
|
|
711
|
+
const offStarted = bridge.onPackEvent('chat-turn-started', (value) => {
|
|
712
|
+
const payload = value as { chatId?: string };
|
|
713
|
+
if (payload.chatId === chatId) {
|
|
714
|
+
awaitingRemoteActiveSnapshotRef.current = true;
|
|
715
|
+
liveStreamEpochRef.current += 1;
|
|
716
|
+
setLiveStream((current) => ({ ...current, active: true }));
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
const offFinished = bridge.onPackEvent('chat-turn-finished', (value) => {
|
|
720
|
+
const payload = value as { chatId?: string };
|
|
721
|
+
if (payload.chatId !== chatId) return;
|
|
722
|
+
awaitingRemoteActiveSnapshotRef.current = false;
|
|
723
|
+
liveStreamEpochRef.current += 1;
|
|
724
|
+
setLiveStream({ active: false });
|
|
725
|
+
void bridge.localBackend.request<{
|
|
726
|
+
messages?: LocalChatMessage[];
|
|
727
|
+
}>({
|
|
728
|
+
method: "GET",
|
|
729
|
+
path: `/api/v2/chats/${encodeURIComponent(chatId)}/messages?limit=200`,
|
|
730
|
+
}).then((response) => {
|
|
731
|
+
if (response.messages) setMessages(chronological(response.messages));
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
return () => {
|
|
735
|
+
offStarted();
|
|
736
|
+
offFinished();
|
|
737
|
+
};
|
|
738
|
+
}, [chatId, setMessages]);
|
|
695
739
|
|
|
696
740
|
// ── 切回恢复:远端回合仍在跑,但本 mount 不是发起者 ──────────────────
|
|
697
741
|
// useChatStream.isStreaming 只在本 mount 自己发起的流时为 true;切回后的
|
|
@@ -735,10 +779,18 @@ function AgentChatView({
|
|
|
735
779
|
useEffect(() => {
|
|
736
780
|
if (!remoteStreaming) return;
|
|
737
781
|
let cancelled = false;
|
|
782
|
+
const epoch = liveStreamEpochRef.current;
|
|
738
783
|
const poll = async () => {
|
|
739
784
|
try {
|
|
740
785
|
const next = await getChatLiveStream(chatId);
|
|
741
|
-
if (!cancelled
|
|
786
|
+
if (!cancelled && liveStreamEpochRef.current === epoch) {
|
|
787
|
+
if (next?.active) {
|
|
788
|
+
awaitingRemoteActiveSnapshotRef.current = false;
|
|
789
|
+
} else if (awaitingRemoteActiveSnapshotRef.current) {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
setLiveStream(next ?? { active: false });
|
|
793
|
+
}
|
|
742
794
|
} catch {
|
|
743
795
|
/* 保持上一帧快照 */
|
|
744
796
|
}
|
|
@@ -807,14 +859,12 @@ function AgentChatView({
|
|
|
807
859
|
wasStreamingRef.current = isStreaming;
|
|
808
860
|
}, [isStreaming, messages]);
|
|
809
861
|
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
// transport.cancelActive() 让主进程 abort LLM / 工具循环。
|
|
862
|
+
// 真正中断后端 agent 循环;流保持运行态直到后端发回 cancelled done,
|
|
863
|
+
// 避免界面提前恢复发送后,新回合被仍在清理的旧回合以 409 拒绝。
|
|
813
864
|
const handleCancel = useCallback(() => {
|
|
814
865
|
transport.cancelActive();
|
|
815
|
-
cancel();
|
|
816
866
|
updateRemoteFollowUps(() => []);
|
|
817
|
-
}, [transport,
|
|
867
|
+
}, [transport, updateRemoteFollowUps]);
|
|
818
868
|
|
|
819
869
|
// Reset the in-flight queue on every new user submit. We do this in a
|
|
820
870
|
// wrapper rather than directly in `handleUnknownEvent`, because there's no
|
|
@@ -1258,6 +1308,15 @@ function AgentChatView({
|
|
|
1258
1308
|
}
|
|
1259
1309
|
inputBanner={
|
|
1260
1310
|
<>
|
|
1311
|
+
<GoalLoopStatusBar
|
|
1312
|
+
chatId={chatId}
|
|
1313
|
+
goal={goal}
|
|
1314
|
+
loops={loops}
|
|
1315
|
+
onChanged={() => {
|
|
1316
|
+
void refreshGoal();
|
|
1317
|
+
void refreshLoops();
|
|
1318
|
+
}}
|
|
1319
|
+
/>
|
|
1261
1320
|
{/* W6-5 项目信任门控:项目含规则文件但未信任时提示授权。 */}
|
|
1262
1321
|
{showProjectsChrome ? (
|
|
1263
1322
|
<ProjectTrustBanner
|