@tea-agent/loop-agent 0.32.0 → 0.32.1
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/CHANGELOG.md +14 -0
- package/dist/worker/console/static/assets/index-Bpa2qrc-.js +29 -0
- package/dist/worker/console/static/assets/index-BqfFDdnG.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +140 -0
- package/dist/worker/console/static-src/app/useConsoleShell.js +101 -0
- package/dist/worker/console/static-src/app/useOperatorActions.js +304 -0
- package/dist/worker/console/static-src/app/usePrdImport.js +171 -0
- package/dist/worker/console/static-src/app/useRecoveryActions.js +257 -0
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +334 -0
- package/dist/worker/console/static-src/app/useTaskWizard.js +229 -0
- package/dist/worker/console/static-src/chat-view-types.js +2 -0
- package/dist/worker/console/static-src/night/night-types.js +24 -0
- package/dist/worker/console/static-src/night/useNightBoard.js +125 -0
- package/dist/worker/console/static-src/night/useNightWizard.js +171 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +253 -0
- package/dist/worker/console/static-src/operator-chat/format.js +71 -0
- package/dist/worker/console/static-src/operator-chat/refs.js +47 -0
- package/dist/worker/console/static-src/operator-chat/tools-catalog.js +77 -0
- package/dist/worker/console/static-src/operator-chat/types.js +1 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +320 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +209 -0
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +218 -0
- package/dist/worker/console/static-src/operator-chat/useComposer.js +125 -0
- package/dist/worker/console/static-src/operator-chat/useInterview.js +108 -0
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +123 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +207 -0
- package/docs/templates/agent-worker-production-readiness-checklist.md +26 -24
- package/package.json +5 -4
- package/dist/worker/console/static/assets/index-D9gnJn_l.js +0 -29
- package/dist/worker/console/static/assets/index-rajoXwkM.css +0 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { buildTurnProcessGroups } from "../../chat/turn-process.js";
|
|
3
|
+
import { createChatSseEventApplier } from "./chat-sse-events.js";
|
|
4
|
+
import { taskContextUnchanged } from "./format.js";
|
|
5
|
+
const EMPTY_USAGE = {
|
|
6
|
+
inputTokens: 0,
|
|
7
|
+
outputTokens: 0,
|
|
8
|
+
cacheReadTokens: 0,
|
|
9
|
+
cacheWriteTokens: 0,
|
|
10
|
+
totalTokens: 0,
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Thread state + SSE block parsing. The event-application logic itself lives
|
|
14
|
+
* in chat-sse-events.ts (createChatSseEventApplier); this hook owns the state
|
|
15
|
+
* slices it patches and the derived timeline projections.
|
|
16
|
+
*/
|
|
17
|
+
export function useChatThread(params) {
|
|
18
|
+
const { origin, refs, sessionId, setError, setProcessOpen } = params;
|
|
19
|
+
const [messages, setMessages] = useState([]);
|
|
20
|
+
const [toolCalls, setToolCalls] = useState([]);
|
|
21
|
+
const [operationCards, setOperationCards] = useState([]);
|
|
22
|
+
const [artifactCards, setArtifactCards] = useState([]);
|
|
23
|
+
const [historyLimit, setHistoryLimit] = useState(50);
|
|
24
|
+
const [unread, setUnread] = useState(0);
|
|
25
|
+
const [humanGateCards, setHumanGateCards] = useState([]);
|
|
26
|
+
const [interview, setInterview] = useState(null);
|
|
27
|
+
const [taskContext, setTaskContext] = useState(null);
|
|
28
|
+
const [contextLoading, setContextLoading] = useState(false);
|
|
29
|
+
const [usage, setUsage] = useState(EMPTY_USAGE);
|
|
30
|
+
const [streaming, setStreaming] = useState(false);
|
|
31
|
+
/** Assistant bubble id for the in-flight turn; only this row shows the streaming cursor. */
|
|
32
|
+
const [streamingAssistantId, setStreamingAssistantId] = useState(null);
|
|
33
|
+
const [showSystemInMessages] = useState(false);
|
|
34
|
+
// All inputs are stable (state setters + the memoized refs bag), so the
|
|
35
|
+
// applier identity is stable across renders.
|
|
36
|
+
const applyChatSseEvent = useMemo(() => createChatSseEventApplier({
|
|
37
|
+
refs,
|
|
38
|
+
setError,
|
|
39
|
+
setProcessOpen,
|
|
40
|
+
setInterview,
|
|
41
|
+
setHumanGateCards,
|
|
42
|
+
setArtifactCards,
|
|
43
|
+
setStreaming,
|
|
44
|
+
setStreamingAssistantId,
|
|
45
|
+
setUnread,
|
|
46
|
+
setOperationCards,
|
|
47
|
+
setUsage,
|
|
48
|
+
setMessages,
|
|
49
|
+
setToolCalls,
|
|
50
|
+
}), [refs, setError, setProcessOpen]);
|
|
51
|
+
/** Parse + apply one raw SSE block (frames separated by \n\n). Returns the
|
|
52
|
+
* eventId if the block carried an `id:` line (for Last-Event-ID tracking). */
|
|
53
|
+
const applySseBlock = useCallback((block, assistantId, options) => {
|
|
54
|
+
if (options?.targetSessionId &&
|
|
55
|
+
refs.sessionRef.current?.sessionId !== options.targetSessionId)
|
|
56
|
+
return null;
|
|
57
|
+
const lines = block.split("\n");
|
|
58
|
+
let eventName = "message";
|
|
59
|
+
let dataStr = "";
|
|
60
|
+
let eventId = null;
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
if (line.startsWith("event: "))
|
|
63
|
+
eventName = line.slice(7).trim();
|
|
64
|
+
else if (line.startsWith("data: "))
|
|
65
|
+
dataStr += line.slice(6);
|
|
66
|
+
else if (line.startsWith("id: "))
|
|
67
|
+
eventId = line.slice(4).trim();
|
|
68
|
+
}
|
|
69
|
+
if (!dataStr)
|
|
70
|
+
return eventId;
|
|
71
|
+
let data;
|
|
72
|
+
try {
|
|
73
|
+
data = JSON.parse(dataStr);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return eventId;
|
|
77
|
+
}
|
|
78
|
+
const dedupeId = eventId ?? (typeof data.eventId === "string" ? data.eventId : null);
|
|
79
|
+
if (dedupeId) {
|
|
80
|
+
if (refs.seenEventIdsRef.current.has(dedupeId))
|
|
81
|
+
return dedupeId;
|
|
82
|
+
refs.seenEventIdsRef.current.add(dedupeId);
|
|
83
|
+
}
|
|
84
|
+
applyChatSseEvent(eventName, data, assistantId, options);
|
|
85
|
+
return eventId ?? dedupeId;
|
|
86
|
+
}, [applyChatSseEvent, refs]);
|
|
87
|
+
const loadTaskContext = useCallback(async (targetSessionId, options) => {
|
|
88
|
+
const background = options?.background === true;
|
|
89
|
+
if (!background)
|
|
90
|
+
setContextLoading(true);
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(targetSessionId)}/context`, { credentials: "include" });
|
|
93
|
+
if (!res.ok)
|
|
94
|
+
return;
|
|
95
|
+
const body = (await res.json());
|
|
96
|
+
if (body.context &&
|
|
97
|
+
refs.sessionRef.current?.sessionId === targetSessionId) {
|
|
98
|
+
setTaskContext((prev) => taskContextUnchanged(prev, body.context) ? prev : body.context);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (!background)
|
|
103
|
+
setContextLoading(false);
|
|
104
|
+
}
|
|
105
|
+
}, [origin, refs]);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (sessionId)
|
|
108
|
+
void loadTaskContext(sessionId);
|
|
109
|
+
}, [sessionId, loadTaskContext]);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!sessionId)
|
|
112
|
+
return;
|
|
113
|
+
const timer = window.setTimeout(() => {
|
|
114
|
+
void loadTaskContext(sessionId, { background: true });
|
|
115
|
+
}, 350);
|
|
116
|
+
return () => window.clearTimeout(timer);
|
|
117
|
+
}, [sessionId, operationCards, humanGateCards, interview, loadTaskContext]);
|
|
118
|
+
const visibleMessages = useMemo(() => messages
|
|
119
|
+
.filter((m) => m.role !== "system" || showSystemInMessages)
|
|
120
|
+
.slice(-historyLimit), [messages, showSystemInMessages, historyLimit]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const onVisibility = () => {
|
|
123
|
+
if (!document.hidden)
|
|
124
|
+
setUnread(0);
|
|
125
|
+
};
|
|
126
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
127
|
+
return () => document.removeEventListener("visibilitychange", onVisibility);
|
|
128
|
+
}, []);
|
|
129
|
+
const processSummary = useMemo(() => {
|
|
130
|
+
if (toolCalls.length === 0)
|
|
131
|
+
return null;
|
|
132
|
+
const running = toolCalls.filter((t) => t.status === "running");
|
|
133
|
+
const errored = toolCalls.filter((t) => t.status === "error");
|
|
134
|
+
const current = running[running.length - 1];
|
|
135
|
+
let statusLabel = "完成";
|
|
136
|
+
let statusKind = "ok";
|
|
137
|
+
if (errored.length > 0) {
|
|
138
|
+
statusLabel = `${errored.length} 出错`;
|
|
139
|
+
statusKind = "error";
|
|
140
|
+
}
|
|
141
|
+
else if (running.length > 0) {
|
|
142
|
+
statusLabel = current ? `正在调用 ${current.toolName}` : "运行中";
|
|
143
|
+
statusKind = "running";
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
count: toolCalls.length,
|
|
147
|
+
statusLabel,
|
|
148
|
+
statusKind,
|
|
149
|
+
};
|
|
150
|
+
}, [toolCalls]);
|
|
151
|
+
const turnGroups = useMemo(() => buildTurnProcessGroups({
|
|
152
|
+
messages: visibleMessages,
|
|
153
|
+
toolCalls,
|
|
154
|
+
streaming,
|
|
155
|
+
activeAssistantId: streamingAssistantId,
|
|
156
|
+
}), [visibleMessages, toolCalls, streaming, streamingAssistantId]);
|
|
157
|
+
/** Reset all session-scoped projections before adopting a fresh session. */
|
|
158
|
+
const resetForNewSession = useCallback(() => {
|
|
159
|
+
setMessages([]);
|
|
160
|
+
setToolCalls([]);
|
|
161
|
+
setOperationCards([]);
|
|
162
|
+
setHumanGateCards([]);
|
|
163
|
+
setArtifactCards([]);
|
|
164
|
+
setInterview(null);
|
|
165
|
+
setTaskContext(null);
|
|
166
|
+
setUsage(EMPTY_USAGE);
|
|
167
|
+
setStreaming(false);
|
|
168
|
+
setStreamingAssistantId(null);
|
|
169
|
+
setUnread(0);
|
|
170
|
+
setHistoryLimit(50);
|
|
171
|
+
refs.usageResponseKeysRef.current = new Set();
|
|
172
|
+
}, [refs]);
|
|
173
|
+
/** Adopt a freshly activated session's hydrated thread + streaming flags. */
|
|
174
|
+
const applyActivatedSession = useCallback((next) => {
|
|
175
|
+
setMessages(next.messages);
|
|
176
|
+
setToolCalls(next.toolCalls);
|
|
177
|
+
setOperationCards([]);
|
|
178
|
+
setHumanGateCards([]);
|
|
179
|
+
setArtifactCards([]);
|
|
180
|
+
setTaskContext(null);
|
|
181
|
+
setInterview(null);
|
|
182
|
+
setUsage(EMPTY_USAGE);
|
|
183
|
+
refs.usageResponseKeysRef.current = new Set();
|
|
184
|
+
setStreaming(next.streaming);
|
|
185
|
+
setStreamingAssistantId(null);
|
|
186
|
+
setUnread(0);
|
|
187
|
+
setHistoryLimit(50);
|
|
188
|
+
}, [refs]);
|
|
189
|
+
return {
|
|
190
|
+
messages,
|
|
191
|
+
setMessages,
|
|
192
|
+
toolCalls,
|
|
193
|
+
setToolCalls,
|
|
194
|
+
operationCards,
|
|
195
|
+
artifactCards,
|
|
196
|
+
historyLimit,
|
|
197
|
+
setHistoryLimit,
|
|
198
|
+
unread,
|
|
199
|
+
humanGateCards,
|
|
200
|
+
interview,
|
|
201
|
+
setInterview,
|
|
202
|
+
taskContext,
|
|
203
|
+
contextLoading,
|
|
204
|
+
usage,
|
|
205
|
+
streaming,
|
|
206
|
+
setStreaming,
|
|
207
|
+
streamingAssistantId,
|
|
208
|
+
setStreamingAssistantId,
|
|
209
|
+
visibleMessages,
|
|
210
|
+
turnGroups,
|
|
211
|
+
processSummary,
|
|
212
|
+
applyChatSseEvent,
|
|
213
|
+
applySseBlock,
|
|
214
|
+
loadTaskContext,
|
|
215
|
+
resetForNewSession,
|
|
216
|
+
applyActivatedSession,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
2
|
+
import { SHORTCUTS } from "../../chat/shortcuts.js";
|
|
3
|
+
import { confirmationToken } from "./format.js";
|
|
4
|
+
/** Composer state: input text, @file palette, image attachments, /shortcuts,
|
|
5
|
+
* IME composition guards, auto-grow and server-side draft sync. */
|
|
6
|
+
export function useComposer(params) {
|
|
7
|
+
const { origin, sessionId, refs, setError } = params;
|
|
8
|
+
const [input, setInput] = useState("");
|
|
9
|
+
const [fileOptions, setFileOptions] = useState([]);
|
|
10
|
+
const [pendingImages, setPendingImages] = useState([]);
|
|
11
|
+
const [shortcutOpen, setShortcutOpen] = useState(false);
|
|
12
|
+
const [shortcutIndex, setShortcutIndex] = useState(0);
|
|
13
|
+
const textareaRef = useRef(null);
|
|
14
|
+
const handleInputChange = useCallback((value) => {
|
|
15
|
+
setInput(value);
|
|
16
|
+
setShortcutOpen(value.startsWith("/"));
|
|
17
|
+
setShortcutIndex(0);
|
|
18
|
+
}, []);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!sessionId)
|
|
21
|
+
return;
|
|
22
|
+
const match = input
|
|
23
|
+
.slice(0, textareaRef.current?.selectionStart ?? input.length)
|
|
24
|
+
.match(/(?:^|\s)@([^\s@]*)$/);
|
|
25
|
+
if (!match) {
|
|
26
|
+
setFileOptions([]);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const timer = window.setTimeout(() => {
|
|
30
|
+
void fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/files?q=${encodeURIComponent(match[1] ?? "")}`)
|
|
31
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
32
|
+
.then((body) => setFileOptions((body?.files ?? []).map((item) => item.path)));
|
|
33
|
+
}, 150);
|
|
34
|
+
return () => window.clearTimeout(timer);
|
|
35
|
+
}, [origin, sessionId, input]);
|
|
36
|
+
const insertFileReference = useCallback((file) => {
|
|
37
|
+
const caret = textareaRef.current?.selectionStart ?? input.length;
|
|
38
|
+
const before = input.slice(0, caret);
|
|
39
|
+
const start = before.search(/@[^\s@]*$/);
|
|
40
|
+
if (start < 0)
|
|
41
|
+
return;
|
|
42
|
+
setInput(`${input.slice(0, start)}@${file} ${input.slice(caret)}`);
|
|
43
|
+
setFileOptions([]);
|
|
44
|
+
}, [input]);
|
|
45
|
+
const addImagesFromFiles = useCallback((files) => {
|
|
46
|
+
for (const file of Array.from(files).slice(0, 4)) {
|
|
47
|
+
if (!file.type.startsWith("image/") || file.size > 5 * 1024 * 1024) {
|
|
48
|
+
setError("图片仅支持 PNG/JPEG/GIF/WebP,单张不超过 5 MiB");
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const reader = new FileReader();
|
|
52
|
+
reader.onload = () => {
|
|
53
|
+
const value = String(reader.result ?? "");
|
|
54
|
+
const data = value.slice(value.indexOf(",") + 1);
|
|
55
|
+
setPendingImages((items) => [
|
|
56
|
+
...items,
|
|
57
|
+
{
|
|
58
|
+
name: file.name,
|
|
59
|
+
type: "image",
|
|
60
|
+
mimeType: file.type,
|
|
61
|
+
data,
|
|
62
|
+
},
|
|
63
|
+
].slice(0, 4));
|
|
64
|
+
};
|
|
65
|
+
reader.readAsDataURL(file);
|
|
66
|
+
}
|
|
67
|
+
}, [setError]);
|
|
68
|
+
const removeImage = useCallback((image) => {
|
|
69
|
+
setPendingImages((items) => items.filter((item) => item !== image));
|
|
70
|
+
}, []);
|
|
71
|
+
// Auto-grow textarea height.
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
const ta = textareaRef.current;
|
|
74
|
+
if (!ta)
|
|
75
|
+
return;
|
|
76
|
+
ta.style.height = "auto";
|
|
77
|
+
ta.style.height = `${Math.min(ta.scrollHeight, 280)}px`;
|
|
78
|
+
}, [input]);
|
|
79
|
+
// Debounced server-side composer draft sync.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!sessionId)
|
|
82
|
+
return;
|
|
83
|
+
const timer = window.setTimeout(() => {
|
|
84
|
+
void fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/draft`, {
|
|
85
|
+
method: "PUT",
|
|
86
|
+
credentials: "include",
|
|
87
|
+
headers: {
|
|
88
|
+
"content-type": "application/json",
|
|
89
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({ text: input }),
|
|
92
|
+
});
|
|
93
|
+
}, 400);
|
|
94
|
+
return () => window.clearTimeout(timer);
|
|
95
|
+
}, [origin, sessionId, input]);
|
|
96
|
+
const filteredShortcuts = useMemo(() => input.startsWith("/")
|
|
97
|
+
? SHORTCUTS.filter((item) => item.command.startsWith(input.trim().split(/\s+/, 1)[0].toLowerCase()))
|
|
98
|
+
: [], [input]);
|
|
99
|
+
const onCompositionStart = useCallback(() => {
|
|
100
|
+
refs.isComposingRef.current = true;
|
|
101
|
+
}, [refs]);
|
|
102
|
+
const onCompositionEnd = useCallback(() => {
|
|
103
|
+
refs.isComposingRef.current = false;
|
|
104
|
+
refs.compositionEndedAtRef.current = Date.now();
|
|
105
|
+
}, [refs]);
|
|
106
|
+
return {
|
|
107
|
+
input,
|
|
108
|
+
setInput,
|
|
109
|
+
handleInputChange,
|
|
110
|
+
fileOptions,
|
|
111
|
+
pendingImages,
|
|
112
|
+
setPendingImages,
|
|
113
|
+
addImagesFromFiles,
|
|
114
|
+
removeImage,
|
|
115
|
+
shortcutOpen,
|
|
116
|
+
setShortcutOpen,
|
|
117
|
+
shortcutIndex,
|
|
118
|
+
setShortcutIndex,
|
|
119
|
+
filteredShortcuts,
|
|
120
|
+
insertFileReference,
|
|
121
|
+
textareaRef,
|
|
122
|
+
onCompositionStart,
|
|
123
|
+
onCompositionEnd,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { confirmationToken } from "./format.js";
|
|
3
|
+
/** Requirement Interview actions (grill-me flow) driven from the chat surface. */
|
|
4
|
+
export function useInterview(params) {
|
|
5
|
+
const { origin, refs, thread, setError } = params;
|
|
6
|
+
const { interview, setInterview } = thread;
|
|
7
|
+
const [interviewAnswer, setInterviewAnswer] = useState("");
|
|
8
|
+
const startInterview = useCallback(async () => {
|
|
9
|
+
const active = refs.sessionRef.current;
|
|
10
|
+
if (!active)
|
|
11
|
+
return;
|
|
12
|
+
const taskId = window.prompt("Task ID(例如 2026-07-26-my-task)")?.trim();
|
|
13
|
+
if (!taskId)
|
|
14
|
+
return;
|
|
15
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${active.sessionId}/interviews`, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: {
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
20
|
+
},
|
|
21
|
+
credentials: "include",
|
|
22
|
+
body: JSON.stringify({ taskId }),
|
|
23
|
+
});
|
|
24
|
+
const body = (await res.json());
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
setError(body.error?.message ?? `HTTP ${res.status}`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
setInterview({
|
|
30
|
+
interviewSessionId: body.interview?.sessionId,
|
|
31
|
+
taskId,
|
|
32
|
+
state: body.interview?.state,
|
|
33
|
+
question: body.question,
|
|
34
|
+
draft: body.draft?.draft,
|
|
35
|
+
draftSha256: body.draft?.draftSha256,
|
|
36
|
+
assessment: body.assessment,
|
|
37
|
+
});
|
|
38
|
+
}, [origin, refs, setInterview, setError]);
|
|
39
|
+
const answerInterview = useCallback(async (response) => {
|
|
40
|
+
const active = refs.sessionRef.current;
|
|
41
|
+
if (!active || !interview?.interviewSessionId || !interview.question)
|
|
42
|
+
return;
|
|
43
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${active.sessionId}/interviews/${interview.interviewSessionId}/answers`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: {
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
48
|
+
},
|
|
49
|
+
credentials: "include",
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
questionId: interview.question.id,
|
|
52
|
+
response,
|
|
53
|
+
...(response === "override" ? { text: interviewAnswer } : {}),
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
const body = (await res.json());
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
setError(body.error?.message ?? `HTTP ${res.status}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
setInterview((current) => ({
|
|
62
|
+
...(current ?? {}),
|
|
63
|
+
state: body.interview?.state,
|
|
64
|
+
question: body.question,
|
|
65
|
+
draft: body.draft?.draft,
|
|
66
|
+
draftSha256: body.draft?.draftSha256,
|
|
67
|
+
assessment: body.assessment,
|
|
68
|
+
taskKindRecommendation: body.taskKindRecommendation ?? current?.taskKindRecommendation,
|
|
69
|
+
}));
|
|
70
|
+
setInterviewAnswer("");
|
|
71
|
+
}, [origin, refs, interview, interviewAnswer, setInterview, setError]);
|
|
72
|
+
const confirmTaskKind = useCallback(async () => {
|
|
73
|
+
const active = refs.sessionRef.current;
|
|
74
|
+
if (!active ||
|
|
75
|
+
!interview?.interviewSessionId ||
|
|
76
|
+
!interview.taskKindRecommendation)
|
|
77
|
+
return;
|
|
78
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${active.sessionId}/interviews/${interview.interviewSessionId}/task-kind/confirm`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: {
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
83
|
+
},
|
|
84
|
+
credentials: "include",
|
|
85
|
+
body: JSON.stringify({ taskKind: interview.taskKindRecommendation }),
|
|
86
|
+
});
|
|
87
|
+
const body = (await res.json());
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
setError(body.error?.message ?? `HTTP ${res.status}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setInterview((current) => ({
|
|
93
|
+
...(current ?? {}),
|
|
94
|
+
confirmedTaskKind: current?.taskKindRecommendation,
|
|
95
|
+
taskKindRecommendation: undefined,
|
|
96
|
+
draft: body.draft?.draft,
|
|
97
|
+
draftSha256: body.draft?.draftSha256,
|
|
98
|
+
assessment: body.assessment,
|
|
99
|
+
}));
|
|
100
|
+
}, [origin, refs, interview, setInterview, setError]);
|
|
101
|
+
return {
|
|
102
|
+
interviewAnswer,
|
|
103
|
+
setInterviewAnswer,
|
|
104
|
+
startInterview,
|
|
105
|
+
answerInterview,
|
|
106
|
+
confirmTaskKind,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
|
|
3
|
+
/** Left-rail repo tree + file preview tabs state. */
|
|
4
|
+
export function useRepoBrowser(params) {
|
|
5
|
+
const { origin, refs, sessionId, setError } = params;
|
|
6
|
+
const [tree, setTree] = useState({});
|
|
7
|
+
const [expandedDirs, setExpandedDirs] = useState(new Set());
|
|
8
|
+
const [treeError, setTreeError] = useState(null);
|
|
9
|
+
const [previews, setPreviews] = useState([]);
|
|
10
|
+
const [activePreviewPath, setActivePreviewPath] = useState(null);
|
|
11
|
+
const [previewLoading, setPreviewLoading] = useState(false);
|
|
12
|
+
const loadTree = useCallback(async (directory = "") => {
|
|
13
|
+
try {
|
|
14
|
+
setTreeError(null);
|
|
15
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/repo/tree?path=${encodeURIComponent(directory)}`, { credentials: "include" });
|
|
16
|
+
const body = (await res.json());
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
throw new Error(body.error?.message ?? "无法读取仓库目录");
|
|
19
|
+
setTree((current) => ({
|
|
20
|
+
...current,
|
|
21
|
+
[directory]: body.data?.entries ?? [],
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
setTreeError(error instanceof Error ? error.message : String(error));
|
|
26
|
+
}
|
|
27
|
+
}, [origin]);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
void loadTree();
|
|
30
|
+
}, [loadTree]);
|
|
31
|
+
const openPreview = useCallback(async (file) => {
|
|
32
|
+
setPreviewLoading(true);
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/repo/file?path=${encodeURIComponent(file)}`, { credentials: "include" });
|
|
35
|
+
const body = (await res.json());
|
|
36
|
+
if (!res.ok || !body.data)
|
|
37
|
+
throw new Error(body.error?.message ?? "无法预览文件");
|
|
38
|
+
setPreviews((current) => {
|
|
39
|
+
const next = [
|
|
40
|
+
...current.filter((item) => item.path !== body.data.path),
|
|
41
|
+
body.data,
|
|
42
|
+
];
|
|
43
|
+
const activeSessionId = refs.sessionRef.current?.sessionId;
|
|
44
|
+
if (activeSessionId)
|
|
45
|
+
window.localStorage.setItem(`${ACTIVE_CHAT_SESSION_STORAGE_KEY}.previews.${activeSessionId}`, JSON.stringify(next.map((item) => item.path)));
|
|
46
|
+
return next;
|
|
47
|
+
});
|
|
48
|
+
setActivePreviewPath(body.data.path);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
setError(error instanceof Error ? error.message : String(error));
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
setPreviewLoading(false);
|
|
55
|
+
}
|
|
56
|
+
}, [origin, refs, setError]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!sessionId)
|
|
59
|
+
return;
|
|
60
|
+
let paths = [];
|
|
61
|
+
try {
|
|
62
|
+
const stored = JSON.parse(window.localStorage.getItem(`${ACTIVE_CHAT_SESSION_STORAGE_KEY}.previews.${sessionId}`) ?? "[]");
|
|
63
|
+
if (Array.isArray(stored))
|
|
64
|
+
paths = stored
|
|
65
|
+
.filter((item) => typeof item === "string")
|
|
66
|
+
.slice(0, 10);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Invalid local preference is ignored; file content is always re-fetched.
|
|
70
|
+
}
|
|
71
|
+
if (!paths.length)
|
|
72
|
+
return;
|
|
73
|
+
void Promise.all(paths.map(async (file) => {
|
|
74
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/repo/file?path=${encodeURIComponent(file)}`, { credentials: "include" });
|
|
75
|
+
if (!res.ok)
|
|
76
|
+
return undefined;
|
|
77
|
+
return (await res.json()).data;
|
|
78
|
+
})).then((restored) => {
|
|
79
|
+
if (refs.sessionRef.current?.sessionId !== sessionId)
|
|
80
|
+
return;
|
|
81
|
+
const available = restored.filter((item) => Boolean(item));
|
|
82
|
+
setPreviews(available);
|
|
83
|
+
setActivePreviewPath(available.at(-1)?.path ?? null);
|
|
84
|
+
});
|
|
85
|
+
}, [origin, sessionId, refs]);
|
|
86
|
+
const toggleDir = useCallback((dir) => {
|
|
87
|
+
setExpandedDirs((current) => {
|
|
88
|
+
const next = new Set(current);
|
|
89
|
+
if (next.has(dir))
|
|
90
|
+
next.delete(dir);
|
|
91
|
+
else
|
|
92
|
+
next.add(dir);
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
if (!tree[dir])
|
|
96
|
+
void loadTree(dir);
|
|
97
|
+
}, [tree, loadTree]);
|
|
98
|
+
const closePreview = useCallback((file) => {
|
|
99
|
+
setPreviews((current) => {
|
|
100
|
+
const next = current.filter((item) => item.path !== file);
|
|
101
|
+
if (sessionId)
|
|
102
|
+
window.localStorage.setItem(`${ACTIVE_CHAT_SESSION_STORAGE_KEY}.previews.${sessionId}`, JSON.stringify(next.map((item) => item.path)));
|
|
103
|
+
return next;
|
|
104
|
+
});
|
|
105
|
+
setActivePreviewPath((current) => current === file
|
|
106
|
+
? (previews.find((item) => item.path !== file)?.path ?? null)
|
|
107
|
+
: current);
|
|
108
|
+
}, [previews, sessionId]);
|
|
109
|
+
return {
|
|
110
|
+
tree,
|
|
111
|
+
expandedDirs,
|
|
112
|
+
treeError,
|
|
113
|
+
previews,
|
|
114
|
+
activePreviewPath,
|
|
115
|
+
setActivePreviewPath,
|
|
116
|
+
previewLoading,
|
|
117
|
+
setPreviews,
|
|
118
|
+
loadTree,
|
|
119
|
+
openPreview,
|
|
120
|
+
closePreview,
|
|
121
|
+
toggleDir,
|
|
122
|
+
};
|
|
123
|
+
}
|