|
|
|
@@ -1,7 +1,7 @@
|
|
|
|
|
// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
|
|
|
|
|
// Responsibility: Session-first Workbench state orchestration for selection, turn admission, and trace lifecycle rendering.
|
|
|
|
|
|
|
|
|
|
import { computed, ref } from "vue";
|
|
|
|
|
import { computed, nextTick, ref } from "vue";
|
|
|
|
|
import { defineStore } from "pinia";
|
|
|
|
|
import { api } from "@/api";
|
|
|
|
|
import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events";
|
|
|
|
@@ -11,7 +11,7 @@ import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, norma
|
|
|
|
|
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
|
|
|
|
|
import { RECENT_DRAFTS_STORAGE_KEY, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
|
|
|
|
|
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
|
|
|
|
|
import { createWorkbenchServerState, reduceWorkbenchServerState, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
|
|
|
|
|
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
|
|
|
|
|
|
|
|
|
|
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
|
|
|
|
|
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
|
|
|
|
@@ -19,17 +19,14 @@ const TRACE_HYDRATION_PAGE_LIMIT = 100;
|
|
|
|
|
const TRACE_HYDRATION_MAX_PAGES = 60;
|
|
|
|
|
const TRACE_HYDRATION_MAX_ATTEMPTS = 3;
|
|
|
|
|
const TRACE_HYDRATION_RETRY_DELAY_MS = 700;
|
|
|
|
|
const PROJECTION_CATCHUP_DELAY_MS = 1_000;
|
|
|
|
|
const PROJECTION_CATCHUP_MAX_ATTEMPTS = 120;
|
|
|
|
|
|
|
|
|
|
const ACTIVE_TURN_GAP_DELAY_MS = 1_000;
|
|
|
|
|
const ACTIVE_TURN_GAP_MAX_ATTEMPTS = 12;
|
|
|
|
|
interface HydrateOptions {
|
|
|
|
|
sessionId?: string | null;
|
|
|
|
|
invalidRouteId?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
const sessions = ref<WorkbenchSessionRecord[]>([]);
|
|
|
|
|
const messages = ref<ChatMessage[]>([]);
|
|
|
|
|
const providerProfile = ref<ProviderProfile>(readString("hwlab.workbench.providerProfile.v1", "codex"));
|
|
|
|
|
const providerOptions = ref<ProviderProfileOption[]>(defaultProviderProfileOptions(providerProfile.value));
|
|
|
|
|
const recentDrafts = ref<DraftEntry[]>(readRecentDrafts());
|
|
|
|
@@ -47,20 +44,23 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
const explicitSessionId = ref<string | null>(initialWorkbenchSessionIdFromLocation());
|
|
|
|
|
const selectionEpoch = ref(0);
|
|
|
|
|
const serverState = ref(createWorkbenchServerState());
|
|
|
|
|
const sessions = computed(() => selectSessionList(serverState.value));
|
|
|
|
|
const sessionStatusAuthority = computed(() => selectSessionStatusAuthority(serverState.value));
|
|
|
|
|
const turnStatusAuthority = computed(() => selectTurnStatusAuthority(serverState.value));
|
|
|
|
|
const traceAuthorityById = computed(() => selectTraceAuthorityById(serverState.value));
|
|
|
|
|
const traceHydrationInFlight = new Set<string>();
|
|
|
|
|
const terminalRealtimeRefreshInFlight = new Set<string>();
|
|
|
|
|
const projectionCatchupAttempts = new Map<string, number>();
|
|
|
|
|
const activeTurnGapAttempts = new Map<string, number>();
|
|
|
|
|
let realtimeStream: WorkbenchEventStream | null = null;
|
|
|
|
|
let realtimeKey = "";
|
|
|
|
|
let realtimeGapTimer: number | null = null;
|
|
|
|
|
let projectionCatchupTimer: number | null = null;
|
|
|
|
|
let activeTurnGapTimer: number | null = null;
|
|
|
|
|
|
|
|
|
|
const activeSession = computed(() => explicitSessionId.value ? sessions.value.find((item) => item.sessionId === explicitSessionId.value) ?? null : null);
|
|
|
|
|
const activeSession = computed(() => selectActiveSession(serverState.value, explicitSessionId.value));
|
|
|
|
|
const selectedSessionId = computed(() => firstNonEmptyString(activeSession.value?.sessionId, explicitSessionId.value));
|
|
|
|
|
const activeSessionId = computed(() => selectedSessionId.value);
|
|
|
|
|
const activeMessages = computed(() => selectActiveMessages(serverState.value, activeSessionId.value));
|
|
|
|
|
const messages = activeMessages;
|
|
|
|
|
const selectedThreadId = computed(() => firstNonEmptyString(activeSession.value?.threadId));
|
|
|
|
|
const sessionTabs = computed(() => sortSessionTabs(sessions.value, activeSessionId.value, sessionStatusAuthority.value));
|
|
|
|
|
const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, sessionsReady: sessionsReady.value }));
|
|
|
|
@@ -97,14 +97,13 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
setActiveSessionSelection(targetSessionId);
|
|
|
|
|
}
|
|
|
|
|
if (targetSessionId) sessionDetailLoadingId.value = targetSessionId;
|
|
|
|
|
const previousMessages = messages.value;
|
|
|
|
|
const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null;
|
|
|
|
|
if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, previousMessages);
|
|
|
|
|
if (selected && !isArchivedSession(selected) && routeRequestId && !routeSessionId && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId);
|
|
|
|
|
if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected);
|
|
|
|
|
if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) rememberSessionDetail(selected);
|
|
|
|
|
if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, null, [], "session archived", { restorePrevious: false });
|
|
|
|
|
if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", null, [], "session URL not found", { restorePrevious: false });
|
|
|
|
|
if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, "session archived");
|
|
|
|
|
if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", "session URL not found");
|
|
|
|
|
const nextSessions = stableSessionList(sessions.value, listedSessions, selected?.sessionId ?? routeSessionId ?? includeSessionId, selected);
|
|
|
|
|
sessions.value = nextSessions;
|
|
|
|
|
rememberSessionList(nextSessions);
|
|
|
|
|
sessionsReady.value = true;
|
|
|
|
|
void refreshSessionStatusAuthority(nextSessions);
|
|
|
|
@@ -137,8 +136,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
error.value = response.error ?? "session create failed";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
sessions.value = mergeSessionIntoList(sessions.value, created);
|
|
|
|
|
rememberSessionDetail(created);
|
|
|
|
|
rememberSessionList(mergeSessionIntoList(sessions.value, created));
|
|
|
|
|
sessionsReady.value = true;
|
|
|
|
|
setActiveSessionSelection(created.sessionId);
|
|
|
|
|
await selectSession(created);
|
|
|
|
@@ -155,8 +153,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
error.value = "invalid session URL";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const previousSessionId = activeSessionId.value;
|
|
|
|
|
const previousMessages = messages.value;
|
|
|
|
|
const existing = seed ?? sessions.value.find((session) => session.sessionId === normalized) ?? null;
|
|
|
|
|
const requestEpoch = beginSessionSelection();
|
|
|
|
|
startWorkbenchSessionSwitch({ sessionId: normalized ?? requestId, source: existing ? "rail" : "deeplink", targetState: existing?.status ?? "unknown", cache: (existing?.messages?.length ?? 0) > 0 ? "warm" : "cold" });
|
|
|
|
@@ -165,26 +161,31 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
loading.value = true;
|
|
|
|
|
if (normalized) setActiveSessionSelection(normalized);
|
|
|
|
|
if (existing) {
|
|
|
|
|
sessions.value = mergeSessionIntoList(sessions.value, existing);
|
|
|
|
|
rememberSessionDetail(existing);
|
|
|
|
|
rememberSessionList(mergeSessionIntoList(sessions.value, existing));
|
|
|
|
|
sessionsReady.value = true;
|
|
|
|
|
}
|
|
|
|
|
const selected = await loadWorkbenchSession(requestId, existing);
|
|
|
|
|
if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) return true;
|
|
|
|
|
if (selected && !isArchivedSession(selected)) {
|
|
|
|
|
applySelectedSessionDetail(selected, previousMessages);
|
|
|
|
|
if (selected && !isArchivedSession(selected) && !normalized && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId);
|
|
|
|
|
if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) {
|
|
|
|
|
clearSessionDetailLoading(requestId);
|
|
|
|
|
clearSwitchingSession(requestId);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (selected && !isArchivedSession(selected)) {
|
|
|
|
|
applySelectedSessionDetail(selected);
|
|
|
|
|
await nextTick();
|
|
|
|
|
loading.value = false;
|
|
|
|
|
clearSessionDetailLoading(requestId);
|
|
|
|
|
clearSwitchingSession(requestId);
|
|
|
|
|
error.value = null;
|
|
|
|
|
await refreshSessions(selected.sessionId);
|
|
|
|
|
finishWorkbenchSessionSwitchFullLoad(selected.sessionId, "ok");
|
|
|
|
|
return activeSessionId.value === selected.sessionId;
|
|
|
|
|
}
|
|
|
|
|
isolateSessionLoadFailure(requestId, previousSessionId, previousMessages, isArchivedSession(selected) ? "session archived" : "session URL not found", { restorePrevious: Boolean(seed && normalized) });
|
|
|
|
|
loading.value = false;
|
|
|
|
|
clearSessionDetailLoading(requestId);
|
|
|
|
|
clearSwitchingSession(requestId);
|
|
|
|
|
loading.value = false;
|
|
|
|
|
isolateSessionLoadFailure(requestId, isArchivedSession(selected) ? "session archived" : "session URL not found");
|
|
|
|
|
failWorkbenchSessionSwitch(normalized ?? requestId);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
@@ -198,7 +199,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
error.value = response.error ?? "session delete failed";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
sessions.value = sessions.value.filter((session) => session.sessionId !== sessionId);
|
|
|
|
|
forgetSession(sessionId);
|
|
|
|
|
await refreshSessions(null);
|
|
|
|
|
const next = sessions.value.find((session) => !isArchivedSession(session)) ?? null;
|
|
|
|
|
replaceActiveSessionSelection(next?.sessionId ?? null);
|
|
|
|
@@ -207,7 +208,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
await selectSession(next);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
messages.value = [];
|
|
|
|
|
restartRealtime("delete-current-session");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -215,7 +215,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
const response = await api.workbench.sessions({ includeSessionId });
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const next = stableSessionList(sessions.value, workbenchSessionsFromPayload(response.data), includeSessionId, activeSession.value);
|
|
|
|
|
sessions.value = next;
|
|
|
|
|
rememberSessionList(next);
|
|
|
|
|
sessionsReady.value = true;
|
|
|
|
|
await refreshSessionStatusAuthority(next);
|
|
|
|
@@ -237,6 +236,26 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
reduceServerState({ type: "session.list", sessions: source });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function forgetSession(sessionId: string): void {
|
|
|
|
|
reduceServerState({ type: "session.forget", sessionId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rememberSessionMessages(sessionId: string | null | undefined, source: ChatMessage[]): void {
|
|
|
|
|
reduceServerState({ type: "session.messages", sessionId, messages: source });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function replaceActiveMessages(source: ChatMessage[]): void {
|
|
|
|
|
rememberSessionMessages(activeSessionId.value, source);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateActiveMessages(project: (source: ChatMessage[]) => ChatMessage[]): void {
|
|
|
|
|
replaceActiveMessages(project(messages.value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendActiveMessages(...items: ChatMessage[]): void {
|
|
|
|
|
replaceActiveMessages([...messages.value, ...items]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setActiveSessionSelection(sessionId: string | null | undefined): void {
|
|
|
|
|
explicitSessionId.value = normalizeWorkbenchSessionId(sessionId) ?? explicitSessionId.value;
|
|
|
|
|
}
|
|
|
|
@@ -296,7 +315,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
const status = statusFromResult(result.status);
|
|
|
|
|
const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status);
|
|
|
|
|
const authoritySessionId = traceResultSessionId(result);
|
|
|
|
|
messages.value = messages.value.map((message) => {
|
|
|
|
|
updateActiveMessages((source) => source.map((message) => {
|
|
|
|
|
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
|
|
|
|
|
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result as AgentChatResultResponse);
|
|
|
|
|
rememberTraceAuthority(runnerTrace);
|
|
|
|
@@ -309,7 +328,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
? firstNonEmptyString((result as AgentChatResultResponse).assistantText, finalResponseText((result as AgentChatResultResponse).finalResponse), replyText, errorText, (result as AgentChatResultResponse).text, (result as AgentChatResultResponse).summary, traceAssistantText, message.text, "Code Agent 已完成,但没有返回可展示的 final response。") ?? message.text
|
|
|
|
|
: message.text;
|
|
|
|
|
return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitMessage(text: string): Promise<void> {
|
|
|
|
@@ -317,7 +336,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
if (!value) return;
|
|
|
|
|
beginSessionSelection();
|
|
|
|
|
if (!composer.value.sessionId) {
|
|
|
|
|
messages.value.push(makeMessage("system", "session_required:请先显式新建或选择 Code Agent session。", "blocked", { title: "需要 Session" }));
|
|
|
|
|
error.value = "session_required:请先显式新建或选择 Code Agent session。";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const steerMode = composer.value.submitMode === "steer";
|
|
|
|
@@ -329,7 +348,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
const providerThreadId = providerThreadIdForRequest(threadId);
|
|
|
|
|
const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" });
|
|
|
|
|
const pending = makeMessage("agent", "", "running", { traceId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running" });
|
|
|
|
|
messages.value.push(user, pending);
|
|
|
|
|
appendActiveMessages(user, pending);
|
|
|
|
|
startWorkbenchSubmitJourney({ traceId, sessionId, entry: submitEntry, backend: providerProfile.value, transport: "sse" });
|
|
|
|
|
chatPending.value = true;
|
|
|
|
|
currentRequest.value = { traceId, sessionId, threadId, status: "running" };
|
|
|
|
@@ -440,18 +459,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
await Promise.all(source.filter(messageNeedsTraceHydration).slice(-12).map((message) => hydrateTraceEventsForMessage(message)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restoreMessagesTraceAuthority(source: ChatMessage[], previous: ChatMessage[] = []): ChatMessage[] {
|
|
|
|
|
const previousTraceById = new Map<string, NonNullable<ChatMessage["runnerTrace"]>>();
|
|
|
|
|
for (const message of previous) {
|
|
|
|
|
const trace = message.runnerTrace;
|
|
|
|
|
const traceId = firstNonEmptyString(message.traceId, trace?.traceId);
|
|
|
|
|
if (!traceId || !trace || !traceHasEvents(trace)) continue;
|
|
|
|
|
previousTraceById.set(traceId, trace);
|
|
|
|
|
}
|
|
|
|
|
function messagesWithTraceAuthority(source: ChatMessage[]): ChatMessage[] {
|
|
|
|
|
return source.map((message) => {
|
|
|
|
|
if (message.role !== "agent") return message;
|
|
|
|
|
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
|
|
|
|
const authority = traceId ? traceAuthorityById.value[traceId] ?? previousTraceById.get(traceId) ?? null : null;
|
|
|
|
|
const authority = traceId ? traceAuthorityById.value[traceId] ?? null : null;
|
|
|
|
|
if (!authority) return message;
|
|
|
|
|
return { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, authority) };
|
|
|
|
|
});
|
|
|
|
@@ -471,7 +483,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
|
|
|
|
|
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
|
|
|
|
|
markWorkbenchTraceEventsReceived({ traceId, events, transport: "rest_gap" });
|
|
|
|
|
messages.value = messages.value.map((message) => {
|
|
|
|
|
updateActiveMessages((source) => source.map((message) => {
|
|
|
|
|
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
|
|
|
|
|
const runnerTrace = mergeRunnerTrace(message.runnerTrace, {
|
|
|
|
|
...(result.runnerTrace ?? {}),
|
|
|
|
@@ -507,7 +519,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
|
|
|
|
|
rememberTraceAuthority(runnerTrace);
|
|
|
|
|
return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
markWorkbenchTraceProjected(traceId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -537,7 +549,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
|
|
|
|
|
function clearSessionMessages(): void {
|
|
|
|
|
const activeTraceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value);
|
|
|
|
|
messages.value = [];
|
|
|
|
|
replaceActiveMessages([]);
|
|
|
|
|
currentRequest.value = null;
|
|
|
|
|
if (activeTraceId) void clearActiveTrace(activeTraceId, "clear-session-messages");
|
|
|
|
|
restartRealtime("clear-session-messages");
|
|
|
|
@@ -545,7 +557,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
|
|
|
|
|
function reattachTrace(traceId: string): void {
|
|
|
|
|
const initial: AgentChatResponse = { status: "running", traceId, turnUrl: `/v1/workbench/turns/${encodeURIComponent(traceId)}` };
|
|
|
|
|
if (!messages.value.some((message) => message.traceId === traceId)) messages.value.push(makeMessage("agent", "", "running", { traceId, sessionId: selectedSessionId.value ?? undefined, threadId: selectedThreadId.value ?? undefined, title: "Code Agent", traceAutoLifecycle: "running" }));
|
|
|
|
|
if (!messages.value.some((message) => message.traceId === traceId)) appendActiveMessages(makeMessage("agent", "", "running", { traceId, sessionId: selectedSessionId.value ?? undefined, threadId: selectedThreadId.value ?? undefined, title: "Code Agent", traceAutoLifecycle: "running" }));
|
|
|
|
|
currentRequest.value = { traceId, sessionId: selectedSessionId.value ?? null, threadId: selectedThreadId.value ?? null, status: initial.status };
|
|
|
|
|
restartRealtime("reattach");
|
|
|
|
|
scheduleRealtimeGapHydration("reattach");
|
|
|
|
@@ -721,27 +733,27 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
hydrateTraceEvents(messages.value)
|
|
|
|
|
]);
|
|
|
|
|
restartRealtime(`gap:${reason}`);
|
|
|
|
|
scheduleProjectionCatchup(reason);
|
|
|
|
|
scheduleActiveTurnGapRefresh(reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scheduleProjectionCatchup(reason: string): void {
|
|
|
|
|
const traceId = activeProjectionCatchupTraceId();
|
|
|
|
|
function scheduleActiveTurnGapRefresh(reason: string): void {
|
|
|
|
|
const traceId = activeTurnGapTraceId();
|
|
|
|
|
if (!traceId) {
|
|
|
|
|
clearProjectionCatchup();
|
|
|
|
|
clearActiveTurnGapRefresh();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (projectionCatchupTimer) return;
|
|
|
|
|
const attempts = projectionCatchupAttempts.get(traceId) ?? 0;
|
|
|
|
|
if (attempts >= PROJECTION_CATCHUP_MAX_ATTEMPTS) return;
|
|
|
|
|
projectionCatchupAttempts.set(traceId, attempts + 1);
|
|
|
|
|
if (activeTurnGapTimer) return;
|
|
|
|
|
const attempts = activeTurnGapAttempts.get(traceId) ?? 0;
|
|
|
|
|
if (attempts >= ACTIVE_TURN_GAP_MAX_ATTEMPTS) return;
|
|
|
|
|
activeTurnGapAttempts.set(traceId, attempts + 1);
|
|
|
|
|
if (typeof window === "undefined") return;
|
|
|
|
|
projectionCatchupTimer = window.setTimeout(() => {
|
|
|
|
|
projectionCatchupTimer = null;
|
|
|
|
|
void hydrateRealtimeGap(`projection-catchup:${reason}`);
|
|
|
|
|
}, PROJECTION_CATCHUP_DELAY_MS);
|
|
|
|
|
activeTurnGapTimer = window.setTimeout(() => {
|
|
|
|
|
activeTurnGapTimer = null;
|
|
|
|
|
void hydrateRealtimeGap(`active-turn-gap:${reason}`);
|
|
|
|
|
}, ACTIVE_TURN_GAP_DELAY_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function activeProjectionCatchupTraceId(): string | null {
|
|
|
|
|
function activeTurnGapTraceId(): string | null {
|
|
|
|
|
const traceId = realtimeTraceId();
|
|
|
|
|
if (!traceId) return null;
|
|
|
|
|
const turn = turnStatusAuthority.value[traceId];
|
|
|
|
@@ -754,11 +766,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
return active ? traceId : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearProjectionCatchup(traceId?: string | null): void {
|
|
|
|
|
if (traceId) projectionCatchupAttempts.delete(traceId);
|
|
|
|
|
else projectionCatchupAttempts.clear();
|
|
|
|
|
if (typeof window !== "undefined" && projectionCatchupTimer) window.clearTimeout(projectionCatchupTimer);
|
|
|
|
|
projectionCatchupTimer = null;
|
|
|
|
|
function clearActiveTurnGapRefresh(traceId?: string | null): void {
|
|
|
|
|
if (traceId) activeTurnGapAttempts.delete(traceId);
|
|
|
|
|
else activeTurnGapAttempts.clear();
|
|
|
|
|
if (typeof window !== "undefined" && activeTurnGapTimer) window.clearTimeout(activeTurnGapTimer);
|
|
|
|
|
activeTurnGapTimer = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function installRealtimeVisibilityHandler(): void {
|
|
|
|
@@ -777,7 +789,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
rememberTurnStatus(traceId, snapshot);
|
|
|
|
|
const status = statusFromResult(snapshot.status);
|
|
|
|
|
const terminal = isTerminalMessageStatus(status);
|
|
|
|
|
messages.value = messages.value.map((message) => {
|
|
|
|
|
updateActiveMessages((source) => source.map((message) => {
|
|
|
|
|
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
|
|
|
|
|
const runnerTrace = mergeRunnerTrace(message.runnerTrace, trace);
|
|
|
|
|
rememberTraceAuthority(runnerTrace);
|
|
|
|
@@ -790,7 +802,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
? firstNonEmptyString(traceAssistantText, finalResponseText(runnerTrace.finalResponse), errorText, message.text) ?? message.text
|
|
|
|
|
: message.text;
|
|
|
|
|
return { ...message, status, text: nextText, traceAutoLifecycle: terminal ? "terminal" : "running", runnerTrace, error: error ?? message.error ?? null, updatedAt: new Date().toISOString() };
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
markWorkbenchTraceProjected(traceId);
|
|
|
|
|
void refreshSessions(trace.sessionId ?? selectedSessionId.value);
|
|
|
|
|
}
|
|
|
|
@@ -798,21 +810,21 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
function completeTrace(traceId: string, result: AgentChatResultResponse): void {
|
|
|
|
|
const authoritySessionId = traceResultSessionId(result);
|
|
|
|
|
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
|
|
|
|
|
clearProjectionCatchup(traceId);
|
|
|
|
|
const resultTrace = recordValue(result.runnerTrace);
|
|
|
|
|
const traceAssistantText = assistantTextFromTraceEvents(firstArray(result.events, result.traceEvents, resultTrace?.events));
|
|
|
|
|
const text = firstNonEmptyString(result.assistantText, finalResponseText(result.finalResponse), typeof result.reply === "string" ? result.reply : result.reply?.content, agentErrorDisplayText(result.error), result.text, result.summary, traceAssistantText) ?? "Code Agent 已完成,但没有返回可展示的 final response。";
|
|
|
|
|
const terminalStatus = result.status === "completed" ? "completed" : statusFromResult(result.status);
|
|
|
|
|
messages.value = messages.value.map((message) => {
|
|
|
|
|
updateActiveMessages((source) => source.map((message) => {
|
|
|
|
|
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
|
|
|
|
|
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
|
|
|
|
|
rememberTraceAuthority(runnerTrace);
|
|
|
|
|
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
|
|
|
|
|
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
|
|
|
|
|
return { ...message, status: terminalStatus, text, traceAutoLifecycle: "terminal", runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
rememberTurnStatus(traceId, result);
|
|
|
|
|
markWorkbenchTraceProjected(traceId);
|
|
|
|
|
clearActiveTurnGapRefresh(traceId);
|
|
|
|
|
void hydrateTraceEvents(messages.value);
|
|
|
|
|
chatPending.value = false;
|
|
|
|
|
currentRequest.value = null;
|
|
|
|
@@ -836,7 +848,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void {
|
|
|
|
|
const authoritySessionId = traceResultSessionId(result);
|
|
|
|
|
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
|
|
|
|
|
messages.value = messages.value.map((message) => {
|
|
|
|
|
updateActiveMessages((source) => source.map((message) => {
|
|
|
|
|
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
|
|
|
|
|
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
|
|
|
|
|
rememberTraceAuthority(runnerTrace);
|
|
|
|
@@ -844,7 +856,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
|
|
|
|
|
const status = statusFromResult(result.status);
|
|
|
|
|
return { ...message, status, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
void hydrateTraceEvents(messages.value);
|
|
|
|
|
void refreshSessions(result.sessionId ?? activeSessionId.value);
|
|
|
|
|
}
|
|
|
|
@@ -862,7 +874,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
reduceServerState({ type: "turn.status", turn: { traceId: input.traceId, status: "running", running: true, terminal: false, sessionId: input.sessionId, threadId: input.threadId, updatedAt: now, loadedAt: now } });
|
|
|
|
|
reduceServerState({ type: "session.status", session: { sessionId: input.sessionId, status: "running", updatedAt: now, lastTraceId: input.traceId, loadedAt: now } });
|
|
|
|
|
const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null;
|
|
|
|
|
sessions.value = mergeSessionIntoList(sessions.value, {
|
|
|
|
|
rememberSessionList(mergeSessionIntoList(sessions.value, {
|
|
|
|
|
...(existing ?? {}),
|
|
|
|
|
sessionId: input.sessionId,
|
|
|
|
|
threadId: firstNonEmptyString(input.threadId, existing?.threadId),
|
|
|
|
@@ -872,12 +884,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
firstUserMessagePreview: firstNonEmptyString(existing?.firstUserMessagePreview, input.userText),
|
|
|
|
|
messageCount: Math.max(existing?.messageCount ?? 0, messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId).length),
|
|
|
|
|
messages: messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId)
|
|
|
|
|
} as WorkbenchSessionRecord);
|
|
|
|
|
rememberSessionList(sessions.value);
|
|
|
|
|
} as WorkbenchSessionRecord));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function markMessage(traceId: string, patch: Partial<ChatMessage>): void {
|
|
|
|
|
messages.value = messages.value.map((message) => message.traceId === traceId && message.role === "agent" ? { ...message, ...patch, updatedAt: new Date().toISOString() } : message);
|
|
|
|
|
updateActiveMessages((source) => source.map((message) => message.traceId === traceId && message.role === "agent" ? { ...message, ...patch, updatedAt: new Date().toISOString() } : message));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function alignOptimisticTurnMessages(traceId: string, lifecycle: AgentChatResponse): string {
|
|
|
|
@@ -886,21 +897,21 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
const userMessageId = firstNonEmptyString(lifecycle.userMessageId);
|
|
|
|
|
const assistantMessageId = firstNonEmptyString(lifecycle.assistantMessageId);
|
|
|
|
|
if (!turnId && !userMessageId && !assistantMessageId && canonicalTraceId === traceId) return canonicalTraceId;
|
|
|
|
|
messages.value = messages.value.map((message) => {
|
|
|
|
|
updateActiveMessages((source) => source.map((message) => {
|
|
|
|
|
if (message.traceId !== traceId) return message;
|
|
|
|
|
const runnerTrace = message.runnerTrace ? { ...message.runnerTrace, traceId: canonicalTraceId } : message.runnerTrace;
|
|
|
|
|
const base = { ...message, traceId: canonicalTraceId, runnerTrace, updatedAt: new Date().toISOString() };
|
|
|
|
|
if (message.role === "user" && userMessageId) return { ...base, id: userMessageId, messageId: userMessageId, turnId };
|
|
|
|
|
if (message.role === "agent" && assistantMessageId) return { ...base, id: assistantMessageId, messageId: assistantMessageId, turnId };
|
|
|
|
|
return turnId ? { ...base, turnId } : base;
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
return canonicalTraceId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function clearActiveTrace(traceId: string, reason: string): Promise<void> {
|
|
|
|
|
void reason;
|
|
|
|
|
if (currentRequest.value?.traceId === traceId) currentRequest.value = null;
|
|
|
|
|
if (!activeProjectionCatchupTraceId()) clearProjectionCatchup(traceId);
|
|
|
|
|
clearActiveTurnGapRefresh(traceId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function beginSessionSelection(): number {
|
|
|
|
@@ -927,11 +938,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
if (!sessionId || sessionDetailLoadingId.value === sessionId) sessionDetailLoadingId.value = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applySelectedSessionDetail(session: WorkbenchSessionRecord, previousMessages: ChatMessage[]): void {
|
|
|
|
|
function applySelectedSessionDetail(session: WorkbenchSessionRecord): void {
|
|
|
|
|
setActiveSessionSelection(session.sessionId);
|
|
|
|
|
rememberSessionDetail(session);
|
|
|
|
|
messages.value = restoreMessagesTraceAuthority(messagesFromSession(session), previousMessages);
|
|
|
|
|
sessions.value = mergeSessionIntoList(sessions.value, session);
|
|
|
|
|
const projectedMessages = messagesWithTraceAuthority(messagesFromSession(session));
|
|
|
|
|
rememberSessionDetail({ ...session, messages: projectedMessages, messageCount: session.messageCount ?? projectedMessages.length });
|
|
|
|
|
sessionsReady.value = true;
|
|
|
|
|
currentRequest.value = null;
|
|
|
|
|
void hydrateTurnStatusAuthority(messages.value);
|
|
|
|
@@ -941,24 +951,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
restartRealtime("apply-selected-session");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isolateSessionLoadFailure(sessionId: string, previousSessionId: string | null, previousMessages: ChatMessage[], message: string, options: { restorePrevious?: boolean } = {}): void {
|
|
|
|
|
sessions.value = sessions.value.filter((item) => item.sessionId !== sessionId || (item.messages?.length ?? 0) > 0);
|
|
|
|
|
if (options.restorePrevious === true && previousSessionId && previousSessionId !== sessionId) {
|
|
|
|
|
const previous = sessions.value.find((item) => item.sessionId === previousSessionId) ?? null;
|
|
|
|
|
replaceActiveSessionSelection(previousSessionId);
|
|
|
|
|
if (previous) {
|
|
|
|
|
rememberSessionDetail(previous);
|
|
|
|
|
messages.value = restoreMessagesTraceAuthority(messagesFromSession(previous), previousMessages);
|
|
|
|
|
void hydrateTurnStatusAuthority(messages.value);
|
|
|
|
|
void hydrateTraceEvents(messages.value);
|
|
|
|
|
reattachRestoredActiveTrace();
|
|
|
|
|
} else {
|
|
|
|
|
messages.value = previousMessages;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
replaceActiveSessionSelection(null);
|
|
|
|
|
messages.value = [];
|
|
|
|
|
}
|
|
|
|
|
function isolateSessionLoadFailure(sessionId: string, message: string): void {
|
|
|
|
|
forgetSession(sessionId);
|
|
|
|
|
replaceActiveSessionSelection(null);
|
|
|
|
|
currentRequest.value = null;
|
|
|
|
|
error.value = message;
|
|
|
|
|
sessionsReady.value = sessions.value.length > 0;
|
|
|
|
@@ -973,7 +968,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
|
|
|
|
|
installRealtimeVisibilityHandler();
|
|
|
|
|
|
|
|
|
|
return { sessions, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, chatPending, error, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, recordActivity };
|
|
|
|
|
return { sessions, messages, activeMessages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, chatPending, error, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, recordActivity };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function workbenchSessionsFromPayload(payload: unknown): WorkbenchSessionRecord[] {
|
|
|
|
@@ -1012,32 +1007,10 @@ async function loadWorkbenchSession(sessionId: string, seed: WorkbenchSessionRec
|
|
|
|
|
const base = detailSession ?? seed;
|
|
|
|
|
if (!base) return null;
|
|
|
|
|
const page = messages.ok ? messages.data : null;
|
|
|
|
|
const pageMessages = Array.isArray(page?.messages) ? repairWorkbenchMessagePage(page.messages.map((message) => normalizeChatMessage(message as ChatMessage)), base) : base.messages;
|
|
|
|
|
const pageMessages = Array.isArray(page?.messages) ? page.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : base.messages;
|
|
|
|
|
return { ...base, sessionId: id, messages: pageMessages, messageCount: page?.total ?? pageMessages?.length ?? base.messageCount };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function repairWorkbenchMessagePage(messages: ChatMessage[], session: WorkbenchSessionRecord): ChatMessage[] {
|
|
|
|
|
const userFallback = firstNonEmptyString(session.firstUserMessagePreview, session.snapshot?.firstUserMessagePreview, session.userPreview, session.title, session.name);
|
|
|
|
|
return messages.map((message, index) => {
|
|
|
|
|
if (message.role !== "user" || firstNonEmptyString(message.text)) return message;
|
|
|
|
|
const matched = findMessageTextFallback(message, index, session.messages ?? []);
|
|
|
|
|
const text = firstNonEmptyString(matched, index === 0 ? userFallback : null);
|
|
|
|
|
return text ? { ...message, text } : message;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findMessageTextFallback(message: ChatMessage, index: number, candidates: ChatMessage[]): string | null {
|
|
|
|
|
const messageId = firstNonEmptyString(message.messageId, message.id);
|
|
|
|
|
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
|
|
|
|
const turnId = firstNonEmptyString(message.turnId);
|
|
|
|
|
const matched = candidates.find((candidate) => candidate.role === message.role && Boolean(
|
|
|
|
|
(messageId && firstNonEmptyString(candidate.messageId, candidate.id) === messageId)
|
|
|
|
|
|| (traceId && firstNonEmptyString(candidate.traceId, candidate.runnerTrace?.traceId) === traceId)
|
|
|
|
|
|| (turnId && firstNonEmptyString(candidate.turnId) === turnId)
|
|
|
|
|
));
|
|
|
|
|
return firstNonEmptyString(matched?.text, candidates.filter((candidate) => candidate.role === message.role)[index]?.text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function providerThreadIdForRequest(threadId: string | null | undefined): string | null {
|
|
|
|
|
const value = firstNonEmptyString(threadId) ?? null;
|
|
|
|
|
if (!value) return null;
|
|
|
|
|