1604 lines
91 KiB
TypeScript
1604 lines
91 KiB
TypeScript
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
|
|
// Responsibility: Workbench client state orchestration for session selection, turn admission, and trace lifecycle rendering.
|
|
|
|
import { computed, ref } from "vue";
|
|
import { defineStore } from "pinia";
|
|
import { api } from "@/api";
|
|
import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events";
|
|
import { assistantTextFromTraceEvents, mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
|
|
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types";
|
|
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
|
|
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
|
|
import { RECENT_DRAFTS_STORAGE_KEY, defaultProviderProfileOptions, isArchivedConversation, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session";
|
|
import { initialWorkbenchConversationIdFromLocation, nextExplicitWorkbenchConversationId, resolveWorkbenchActiveConversationId } from "./workbench-projection";
|
|
import { createWorkbenchServerState, reduceWorkbenchServerState, 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;
|
|
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;
|
|
|
|
interface HydrateOptions {
|
|
conversationId?: string | null;
|
|
}
|
|
|
|
interface SessionCreatePersistenceResult {
|
|
ok: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
export const useWorkbenchStore = defineStore("workbench", () => {
|
|
const projectId = ref(resolveInitialWorkbenchProjectId());
|
|
const workspace = ref<WorkspaceRecord | null>(window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?.workspace ?? null);
|
|
const conversations = ref<ConversationRecord[]>([]);
|
|
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());
|
|
const codeAgentTimeoutMs = ref(readNumber("hwlab.workbench.codeAgentTimeoutMs.v1", DEFAULT_CODE_AGENT_TIMEOUT_MS));
|
|
const gatewayShellTimeoutMs = ref(readNumber("hwlab.workbench.gatewayShellTimeoutMs.v1", DEFAULT_GATEWAY_TIMEOUT_MS));
|
|
const live = ref<LiveSurface | null>(null);
|
|
const loading = ref(false);
|
|
const conversationsReady = ref(false);
|
|
const switchingConversationId = ref<string | null>(null);
|
|
const conversationDetailLoadingId = ref<string | null>(null);
|
|
const chatPending = ref(false);
|
|
const error = ref<string | null>(null);
|
|
const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null });
|
|
const currentRequest = ref<{ traceId: string; conversationId: string | null; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
|
|
const explicitConversationId = ref<string | null>(initialWorkbenchConversationIdFromLocation());
|
|
const workspaceSelectionEpoch = ref(0);
|
|
const serverState = ref(createWorkbenchServerState());
|
|
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 pendingSessionCreates = new Map<string, Promise<SessionCreatePersistenceResult>>();
|
|
let realtimeStream: WorkbenchEventStream | null = null;
|
|
let realtimeKey = "";
|
|
let realtimeGapTimer: number | null = null;
|
|
|
|
const visibleConversations = computed(() => conversations.value);
|
|
const activeConversationId = computed(() => resolveWorkbenchActiveConversationId({ switchingConversationId: switchingConversationId.value, explicitConversationId: explicitConversationId.value }));
|
|
const activeConversation = computed(() => visibleConversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null);
|
|
const selectedSessionId = computed(() => firstNonEmptyString(activeConversation.value?.sessionId));
|
|
const selectedThreadId = computed(() => firstNonEmptyString(activeConversation.value?.threadId));
|
|
const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value, sessionStatusAuthority.value));
|
|
const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, conversationsReady: conversationsReady.value }));
|
|
const conversationDetailLoading = computed(() => Boolean(conversationDetailLoadingId.value || switchingConversationId.value || (loading.value && messages.value.length === 0)));
|
|
const activeProjectId = computed(() => workspaceProjectId(workspace.value, projectId.value));
|
|
const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: visibleConversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value, turnStatusAuthority: turnStatusAuthority.value }));
|
|
|
|
function recordActivity(label = "user-activity"): void {
|
|
const now = Date.now();
|
|
activityRef.value = { lastActivityAt: now, lastActivityIso: new Date(now).toISOString(), waitingFor: "code-agent", lastEventLabel: label };
|
|
}
|
|
|
|
async function hydrate(options: HydrateOptions = {}): Promise<void> {
|
|
const routeConversationId = normalizeWorkbenchConversationId(options.conversationId);
|
|
if (routeConversationId) setActiveConversationSelection(routeConversationId);
|
|
const requestEpoch = workspaceSelectionEpoch.value;
|
|
const previousConversationId = activeConversationId.value;
|
|
if (routeConversationId) conversationDetailLoadingId.value = routeConversationId;
|
|
conversationsReady.value = conversations.value.length > 0;
|
|
loading.value = true;
|
|
error.value = null;
|
|
const workspaceResult = await api.workbench.workspace(projectId.value);
|
|
if (!workspaceResult.ok) {
|
|
clearConversationDetailLoading(routeConversationId);
|
|
loading.value = false;
|
|
error.value = workspaceResult.error ?? "workspace unavailable";
|
|
return;
|
|
}
|
|
const nextWorkspace = workspaceResult.data?.workspace ?? null;
|
|
const nextProjectId = workspaceProjectId(nextWorkspace, projectId.value);
|
|
rememberWorkspaceSnapshot(nextProjectId, nextWorkspace);
|
|
projectId.value = nextProjectId;
|
|
const selectedConversationId = routeConversationId ?? activeConversationId.value ?? selectedConversationIdFromWorkspace(nextWorkspace);
|
|
bootstrapActiveConversationSelection(selectedConversationId);
|
|
const conversationsPromise = api.workbench.conversations(nextProjectId, { includeConversationId: selectedConversationId });
|
|
if (selectedConversationId) conversationDetailLoadingId.value = selectedConversationId;
|
|
const selectedConversationResult = selectedConversationId ? await api.workbench.conversation(selectedConversationId, { projectId: nextProjectId }) : null;
|
|
clearConversationDetailLoading(selectedConversationId);
|
|
const selectedConversation = selectedConversationResult?.ok && !isArchivedConversation(selectedConversationResult.data?.conversation) ? selectedConversationResult.data?.conversation ?? null : null;
|
|
rememberConversationDetail(selectedConversation);
|
|
if (selectedConversation) conversations.value = mergeConversationIntoList(conversations.value, selectedConversation);
|
|
if (selectedConversationId && selectedConversationResult && (!selectedConversationResult.ok || isArchivedConversation(selectedConversationResult.data?.conversation))) error.value = isArchivedConversation(selectedConversationResult.data?.conversation) ? "session archived" : selectedConversationResult.error ?? "conversation unavailable";
|
|
const workspaceSnapshot = selectedConversation
|
|
? workspaceWithSelectedConversation(nextWorkspace, selectedConversation, conversationProjectId(selectedConversation, nextProjectId))
|
|
: nextWorkspace;
|
|
if (shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: workspaceSnapshot })) {
|
|
workspace.value = workspaceSnapshot;
|
|
rememberWorkspaceSnapshot(nextProjectId, workspaceSnapshot);
|
|
providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value;
|
|
rememberWorkbenchProjectId(workspaceProjectId(workspace.value, nextProjectId));
|
|
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(selectedConversation, selectedConversationId, previousConversationId, messages.value), messages.value);
|
|
void hydrateTurnStatusAuthority(messages.value);
|
|
void hydrateTerminalMessageDiagnostics();
|
|
void hydrateTraceEvents(messages.value);
|
|
reattachRestoredActiveTrace();
|
|
}
|
|
const conversationsResult = await conversationsPromise;
|
|
let nextConversations = conversations.value;
|
|
if (conversationsResult.ok) {
|
|
nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationId, selectedConversation);
|
|
conversations.value = nextConversations;
|
|
rememberConversationList(nextConversations);
|
|
conversationsReady.value = true;
|
|
void refreshSessionStatusAuthority(nextConversations);
|
|
} else {
|
|
if (selectedConversation) {
|
|
nextConversations = mergeConversationIntoList(nextConversations, selectedConversation);
|
|
conversations.value = nextConversations;
|
|
rememberConversationDetail(selectedConversation);
|
|
}
|
|
conversationsReady.value = conversations.value.length > 0;
|
|
error.value = conversations.value.length > 0 ? null : conversationsResult.error ?? "session list unavailable";
|
|
}
|
|
loading.value = false;
|
|
await refreshProviderOptions();
|
|
restartRealtime("hydrate");
|
|
}
|
|
|
|
async function refreshLive(): Promise<void> {
|
|
const [healthLive, health, restIndex, hwpodSpecs, hwpodNodeOps, liveBuilds] = await Promise.all([
|
|
liveCall("health/live", api.healthLive),
|
|
liveCall("health", api.health),
|
|
liveCall("REST index", api.restIndex),
|
|
liveCall("hwpod specs", api.hwpod.specs),
|
|
liveCall("hwpod node ops", api.hwpod.nodeOpsHealth),
|
|
liveCall("live builds", api.liveBuilds)
|
|
]);
|
|
live.value = { healthLive, health, restIndex, hwpodSpecs, hwpodNodeOps, liveBuilds, loadedAt: new Date().toISOString() };
|
|
}
|
|
|
|
async function createSession(): Promise<void> {
|
|
const current = await ensureWorkspace();
|
|
if (!current) return;
|
|
const requestEpoch = beginWorkspaceSelection();
|
|
const selectedProjectId = activeProjectId.value;
|
|
const optimisticConversation = makeOptimisticConversation(selectedProjectId);
|
|
setActiveConversationSelection(optimisticConversation.conversationId);
|
|
conversations.value = mergeConversationIntoList(conversations.value, optimisticConversation);
|
|
rememberConversationDetail(optimisticConversation);
|
|
conversationsReady.value = true;
|
|
workspace.value = workspaceWithSelectedConversation(current, optimisticConversation, selectedProjectId);
|
|
rememberWorkspaceSnapshot(selectedProjectId, workspace.value);
|
|
messages.value = [];
|
|
currentRequest.value = null;
|
|
loading.value = false;
|
|
error.value = null;
|
|
restartRealtime("create-session-optimistic");
|
|
const persistence = persistOptimisticSession(optimisticConversation, selectedProjectId, current.workspaceId, requestEpoch);
|
|
pendingSessionCreates.set(optimisticConversation.conversationId, persistence);
|
|
void persistence.then((result) => {
|
|
if (result.ok && pendingSessionCreates.get(optimisticConversation.conversationId) === persistence) pendingSessionCreates.delete(optimisticConversation.conversationId);
|
|
});
|
|
}
|
|
|
|
async function selectConversation(conversation: ConversationRecord): Promise<void> {
|
|
const current = await ensureWorkspace();
|
|
if (!current) return;
|
|
const previousConversationId = activeConversationId.value;
|
|
const previousMessages = messages.value;
|
|
const requestEpoch = beginWorkspaceSelection();
|
|
startWorkbenchSessionSwitch({ conversationId: conversation.conversationId, source: "rail", targetState: conversation.status, cache: (conversation.messages?.length ?? 0) > 0 ? "warm" : "cold" });
|
|
const tabProjectId = conversation.projectId ?? activeProjectId.value;
|
|
switchingConversationId.value = conversation.conversationId;
|
|
setActiveConversationSelection(conversation.conversationId);
|
|
conversationDetailLoadingId.value = conversation.conversationId;
|
|
conversations.value = mergeConversationIntoList(conversations.value, conversation);
|
|
rememberConversationDetail(conversation);
|
|
conversationsReady.value = true;
|
|
workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId);
|
|
rememberWorkspaceSnapshot(tabProjectId, workspace.value);
|
|
void refreshSessionStatusById(sessionIdFromConversation(conversation));
|
|
loading.value = true;
|
|
const persistPromise = api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue" });
|
|
const detailResponse = await api.workbench.conversation(conversation.conversationId, { projectId: tabProjectId });
|
|
const response = await persistPromise;
|
|
loading.value = false;
|
|
clearConversationDetailLoading(conversation.conversationId);
|
|
clearSwitchingConversation(conversation.conversationId);
|
|
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) return;
|
|
const selectedConversation = detailResponse.ok && !isArchivedConversation(detailResponse.data?.conversation) ? detailResponse.data?.conversation ?? null : null;
|
|
if (selectedConversation) {
|
|
const selectedProjectId = conversationProjectId(selectedConversation, tabProjectId);
|
|
applySelectedConversationDetail(selectedConversation, response.ok ? response.data?.workspace ?? current : workspace.value ?? current, selectedProjectId, null, messages.value);
|
|
error.value = null;
|
|
void persistSelectedConversation(selectedConversation, selectedProjectId, response);
|
|
await refreshSelectedSessionStatus(selectedConversation);
|
|
await refreshConversations(conversation.conversationId);
|
|
finishWorkbenchSessionSwitchFullLoad(conversation.conversationId, "ok");
|
|
return;
|
|
}
|
|
if (response.status === 404 && await retrySelectConversationWithFreshWorkspace(conversation, tabProjectId, requestEpoch)) return;
|
|
isolateConversationLoadFailure(conversation.conversationId, previousConversationId, previousMessages, isArchivedConversation(detailResponse.data?.conversation) ? "session archived" : detailResponse.error ?? response.error ?? "conversation unavailable");
|
|
failWorkbenchSessionSwitch(conversation.conversationId);
|
|
}
|
|
|
|
async function selectConversationById(conversationId: string): Promise<boolean> {
|
|
const normalized = normalizeWorkbenchConversationId(conversationId);
|
|
if (!normalized) {
|
|
error.value = "invalid session URL";
|
|
return false;
|
|
}
|
|
const previousConversationId = activeConversationId.value;
|
|
const previousMessages = messages.value;
|
|
setActiveConversationSelection(normalized);
|
|
const current = await ensureWorkspace();
|
|
if (!current) return false;
|
|
if (activeConversationId.value === normalized && messages.value.length > 0) {
|
|
const conversation = activeConversation.value ?? visibleConversations.value.find((item) => item.conversationId === normalized) ?? null;
|
|
if (conversation) await persistSelectedConversation(conversation, conversationProjectId(conversation, activeProjectId.value));
|
|
return true;
|
|
}
|
|
const requestEpoch = beginWorkspaceSelection();
|
|
startWorkbenchSessionSwitch({ conversationId: normalized, source: "deeplink", targetState: "unknown", cache: "cold" });
|
|
switchingConversationId.value = normalized;
|
|
conversationDetailLoadingId.value = normalized;
|
|
const existing = visibleConversations.value.find((conversation) => conversation.conversationId === normalized) ?? null;
|
|
if (existing && (existing.messages?.length ?? 0) > 0) {
|
|
await selectConversation(existing);
|
|
return activeConversationId.value === normalized;
|
|
}
|
|
const response = await api.workbench.conversation(normalized, { projectId: activeProjectId.value });
|
|
const conversation = response.data?.conversation ?? null;
|
|
if (!response.ok || !conversation || isArchivedConversation(conversation)) {
|
|
if (isCurrentWorkspaceSelection(requestEpoch, normalized)) clearSwitchingConversation(normalized);
|
|
clearConversationDetailLoading(normalized);
|
|
isolateConversationLoadFailure(normalized, previousConversationId, previousMessages, isArchivedConversation(conversation) ? "session archived" : response.error ?? "session URL not found");
|
|
failWorkbenchSessionSwitch(normalized);
|
|
return false;
|
|
}
|
|
const selectedProjectId = conversationProjectId(conversation, activeProjectId.value);
|
|
applySelectedConversationDetail(conversation, current, selectedProjectId, null, messages.value);
|
|
clearSwitchingConversation(normalized);
|
|
clearConversationDetailLoading(normalized);
|
|
error.value = null;
|
|
void refreshSessionStatusById(sessionIdFromConversation(conversation));
|
|
void persistSelectedConversation(conversation, selectedProjectId);
|
|
void refreshConversations(normalized);
|
|
finishWorkbenchSessionSwitchFullLoad(normalized, "ok");
|
|
return activeConversationId.value === normalized;
|
|
}
|
|
|
|
async function deleteCurrentSession(): Promise<void> {
|
|
const conversationId = activeConversationId.value;
|
|
if (!conversationId) return;
|
|
const response = await api.workbench.deleteConversation(conversationId, { projectId: activeProjectId.value, workspaceId: workspace.value?.workspaceId, updatedByClient: "cloud-web-vue" });
|
|
if (response.ok) {
|
|
workspace.value = response.data?.workspace ?? workspace.value;
|
|
rememberWorkspaceSnapshot(activeProjectId.value, workspace.value);
|
|
const selectedConversationId = selectedConversationIdFromWorkspace(workspace.value);
|
|
const selectedConversation = workspace.value?.selectedConversation ?? null;
|
|
replaceActiveConversationSelection(selectedConversationId);
|
|
rememberConversationDetail(selectedConversation);
|
|
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(selectedConversation, selectedConversationId, null, []), messages.value);
|
|
currentRequest.value = null;
|
|
void hydrateTurnStatusAuthority(messages.value);
|
|
await refreshConversations(selectedConversationIdFromWorkspace(workspace.value));
|
|
}
|
|
}
|
|
|
|
async function refreshConversations(includeConversationId: string | null = currentListIncludeConversationId()): Promise<void> {
|
|
const response = await api.workbench.conversations(activeProjectId.value, { includeConversationId });
|
|
if (response.ok) {
|
|
conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId, activeConversation.value);
|
|
rememberConversationList(conversations.value);
|
|
conversationsReady.value = true;
|
|
await refreshSessionStatusAuthority(conversations.value);
|
|
return;
|
|
}
|
|
conversationsReady.value = conversations.value.length > 0;
|
|
if (conversations.value.length === 0) error.value = response.error ?? "session list unavailable";
|
|
}
|
|
|
|
function reduceServerState(action: WorkbenchServerAction): void {
|
|
serverState.value = reduceWorkbenchServerState(serverState.value, action);
|
|
}
|
|
|
|
function rememberWorkspaceSnapshot(snapshotProjectId: string, snapshot: WorkspaceRecord | null): void {
|
|
reduceServerState({ type: "workspace.snapshot", projectId: snapshotProjectId, workspace: snapshot });
|
|
}
|
|
|
|
function rememberConversationDetail(conversation: ConversationRecord | null | undefined): void {
|
|
reduceServerState({ type: "conversation.detail", conversation });
|
|
}
|
|
|
|
function rememberConversationList(source: ConversationRecord[]): void {
|
|
reduceServerState({ type: "conversation.list", conversations: source });
|
|
}
|
|
|
|
async function persistOptimisticSession(conversation: ConversationRecord, selectedProjectId: string, workspaceId: string, requestEpoch: number): Promise<SessionCreatePersistenceResult> {
|
|
try {
|
|
const response = await api.workbench.selectConversation(workspaceId, {
|
|
projectId: selectedProjectId,
|
|
create: true,
|
|
conversationId: conversation.conversationId,
|
|
sessionId: conversation.sessionId,
|
|
threadId: providerThreadIdForRequest(conversation.threadId),
|
|
updatedByClient: "cloud-web-vue-fast-create"
|
|
});
|
|
if (!response.ok || !response.data?.workspace) {
|
|
if (activeConversationId.value === conversation.conversationId) error.value = response.error ?? "session create failed";
|
|
return { ok: false, error: response.error ?? "session create failed" };
|
|
}
|
|
const persistedWorkspace = response.data.workspace;
|
|
const persistedSelectedId = selectedConversationIdFromWorkspace(persistedWorkspace) ?? conversation.conversationId;
|
|
let persistedConversation = persistedWorkspace.selectedConversation?.conversationId
|
|
? persistedWorkspace.selectedConversation
|
|
: null;
|
|
if ((!persistedConversation || persistedConversation.conversationId !== persistedSelectedId) && persistedSelectedId !== conversation.conversationId) {
|
|
const detail = await api.workbench.conversation(persistedSelectedId, { projectId: selectedProjectId });
|
|
persistedConversation = detail.ok ? detail.data?.conversation ?? persistedConversation : persistedConversation;
|
|
}
|
|
persistedConversation = persistedConversation ?? conversation;
|
|
const authoritativeConversationId = persistedConversation.conversationId;
|
|
if (authoritativeConversationId !== conversation.conversationId) {
|
|
pendingSessionCreates.delete(conversation.conversationId);
|
|
conversations.value = conversations.value.filter((item) => item.conversationId !== conversation.conversationId);
|
|
}
|
|
rememberConversationDetail(persistedConversation);
|
|
conversations.value = mergeConversationIntoList(conversations.value, persistedConversation);
|
|
if (activeConversationId.value === conversation.conversationId || activeConversationId.value === authoritativeConversationId) {
|
|
replaceActiveConversationSelection(authoritativeConversationId);
|
|
const persistedProjectId = conversationProjectId(persistedConversation, selectedProjectId);
|
|
workspace.value = workspaceWithSelectedConversation(persistedWorkspace, persistedConversation, persistedProjectId);
|
|
rememberWorkspaceSnapshot(persistedProjectId, workspace.value);
|
|
error.value = null;
|
|
if (messages.value.length === 0) {
|
|
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(persistedConversation, conversation.conversationId, null, []), messages.value);
|
|
}
|
|
void hydrateTurnStatusAuthority(messages.value);
|
|
void hydrateTraceEvents(messages.value);
|
|
void refreshSelectedSessionStatus(persistedConversation);
|
|
void refreshConversations(conversation.conversationId);
|
|
restartRealtime(requestEpoch === workspaceSelectionEpoch.value ? "create-session" : "create-session-persisted");
|
|
}
|
|
return { ok: true, error: null };
|
|
} catch (cause) {
|
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
if (activeConversationId.value === conversation.conversationId) error.value = message;
|
|
return { ok: false, error: message };
|
|
}
|
|
}
|
|
|
|
async function waitForPendingSessionCreate(conversationId: string): Promise<SessionCreatePersistenceResult> {
|
|
const pending = pendingSessionCreates.get(conversationId);
|
|
if (!pending) return { ok: true, error: null };
|
|
const result = await pending;
|
|
if (result.ok) {
|
|
if (pendingSessionCreates.get(conversationId) === pending) pendingSessionCreates.delete(conversationId);
|
|
return result;
|
|
}
|
|
const recovered = await recoverPendingSessionCreate(conversationId, result.error);
|
|
if (recovered.ok && pendingSessionCreates.get(conversationId) === pending) pendingSessionCreates.delete(conversationId);
|
|
return recovered;
|
|
}
|
|
|
|
async function recoverPendingSessionCreate(conversationId: string, originalError: string | null): Promise<SessionCreatePersistenceResult> {
|
|
const normalized = normalizeWorkbenchConversationId(conversationId);
|
|
if (!normalized) return { ok: false, error: originalError ?? "session create failed" };
|
|
const selectedProjectId = activeProjectId.value;
|
|
const [workspaceResult, conversationResult] = await Promise.all([
|
|
api.workbench.workspace(selectedProjectId),
|
|
api.workbench.conversation(normalized, { projectId: selectedProjectId })
|
|
]);
|
|
const workspaceSnapshot = workspaceResult.ok ? workspaceResult.data?.workspace ?? null : null;
|
|
const workspaceSelectedId = selectedConversationIdFromWorkspace(workspaceSnapshot);
|
|
const workspaceConversation = workspaceSnapshot?.selectedConversation?.conversationId === normalized ? workspaceSnapshot.selectedConversation : null;
|
|
const conversation = conversationResult.ok && !isArchivedConversation(conversationResult.data?.conversation) ? conversationResult.data?.conversation ?? null : null;
|
|
if (workspaceSelectedId !== normalized && !workspaceConversation && !conversation) {
|
|
return { ok: false, error: originalError ?? workspaceResult.error ?? conversationResult.error ?? "session create failed" };
|
|
}
|
|
const authoritativeConversation = conversation ?? workspaceConversation ?? visibleConversations.value.find((item) => item.conversationId === normalized) ?? null;
|
|
if (authoritativeConversation) {
|
|
rememberConversationDetail(authoritativeConversation);
|
|
conversations.value = mergeConversationIntoList(conversations.value, authoritativeConversation);
|
|
}
|
|
if (workspaceSnapshot && activeConversationId.value === normalized) {
|
|
const persistedProjectId = conversationProjectId(authoritativeConversation, selectedProjectId);
|
|
workspace.value = authoritativeConversation ? workspaceWithSelectedConversation(workspaceSnapshot, authoritativeConversation, persistedProjectId) : workspaceSnapshot;
|
|
rememberWorkspaceSnapshot(persistedProjectId, workspace.value);
|
|
error.value = null;
|
|
void hydrateTurnStatusAuthority(messages.value);
|
|
void hydrateTraceEvents(messages.value);
|
|
if (authoritativeConversation) void refreshSelectedSessionStatus(authoritativeConversation);
|
|
void refreshConversations(normalized);
|
|
restartRealtime("create-session-recovered");
|
|
}
|
|
return { ok: true, error: null };
|
|
}
|
|
|
|
function setActiveConversationSelection(conversationId: string | null | undefined): void {
|
|
explicitConversationId.value = nextExplicitWorkbenchConversationId(explicitConversationId.value, conversationId);
|
|
}
|
|
|
|
function replaceActiveConversationSelection(conversationId: string | null | undefined): void {
|
|
explicitConversationId.value = normalizeWorkbenchConversationId(conversationId);
|
|
}
|
|
|
|
function bootstrapActiveConversationSelection(conversationId: string | null | undefined): void {
|
|
if (!explicitConversationId.value) setActiveConversationSelection(conversationId);
|
|
}
|
|
|
|
async function refreshSessionStatusAuthority(source: ConversationRecord[] = conversations.value): Promise<void> {
|
|
await Promise.all(uniqueSessionIds(source).map((sessionId) => refreshSessionStatusById(sessionId)));
|
|
}
|
|
|
|
async function refreshSelectedSessionStatus(fallbackConversation?: ConversationRecord | null): Promise<void> {
|
|
await refreshSessionStatusById(firstNonEmptyString(
|
|
fallbackConversation ? sessionIdFromConversation(fallbackConversation) : null,
|
|
selectedSessionId.value
|
|
));
|
|
}
|
|
|
|
async function refreshSessionStatusById(sessionId: string | null | undefined): Promise<void> {
|
|
const id = firstNonEmptyString(sessionId);
|
|
if (!id) return;
|
|
const response = await api.agent.getAgentSession(id);
|
|
const session = response.data?.session && typeof response.data.session === "object" ? response.data.session : null;
|
|
if (!response.ok || !session) {
|
|
reduceServerState({ type: "session.forget", sessionId: id });
|
|
return;
|
|
}
|
|
const returnedSessionId = firstNonEmptyString(session.sessionId, id);
|
|
if (returnedSessionId !== id) return;
|
|
reduceServerState({
|
|
type: "session.status",
|
|
session: {
|
|
sessionId: id,
|
|
status: firstNonEmptyString(session.status) ?? null,
|
|
updatedAt: firstNonEmptyString(session.updatedAt) ?? null,
|
|
lastTraceId: firstNonEmptyString(session.lastTraceId) ?? null,
|
|
loadedAt: new Date().toISOString()
|
|
}
|
|
});
|
|
}
|
|
|
|
async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value): Promise<void> {
|
|
await Promise.all(uniqueTraceIds(source).slice(-12).map((traceId) => refreshTurnStatusByTraceId(traceId)));
|
|
}
|
|
|
|
async function refreshTurnStatusByTraceId(traceId: string | null | undefined): Promise<void> {
|
|
const id = firstNonEmptyString(traceId);
|
|
if (!id) return;
|
|
const response = await api.agent.getAgentTurn(id, activeProjectId.value, 8000, () => activityRef.value);
|
|
if (response.ok && response.data) {
|
|
applyTurnStatusSnapshot(id, response.data);
|
|
return;
|
|
}
|
|
reduceServerState({ type: "turn.forget", traceId: id });
|
|
}
|
|
|
|
function applyTurnStatusSnapshot(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
|
|
rememberTurnStatus(traceId, result);
|
|
syncTurnStatusToMessage(traceId, result);
|
|
}
|
|
|
|
function rememberTurnStatus(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
|
|
const id = firstNonEmptyString(result.traceId, traceId);
|
|
if (!id) return;
|
|
const status = normalizedStatusText(result.status) ?? null;
|
|
const running = (result as AgentChatResultResponse).running === true || isTraceActiveStatus(status);
|
|
const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status);
|
|
reduceServerState({
|
|
type: "turn.status",
|
|
turn: {
|
|
traceId: id,
|
|
status,
|
|
running,
|
|
terminal,
|
|
conversationId: firstNonEmptyString((result as AgentChatResultResponse).conversationId) ?? null,
|
|
sessionId: firstNonEmptyString(result.sessionId) ?? null,
|
|
threadId: firstNonEmptyString(result.threadId) ?? null,
|
|
updatedAt: firstNonEmptyString((result as AgentChatResultResponse).updatedAt) ?? null,
|
|
loadedAt: new Date().toISOString()
|
|
}
|
|
});
|
|
}
|
|
|
|
function syncTurnStatusToMessage(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
|
|
const status = statusFromResult(result.status);
|
|
const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status);
|
|
messages.value = messages.value.map((message) => {
|
|
if (message.traceId !== traceId || message.role !== "agent") return message;
|
|
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result as AgentChatResultResponse);
|
|
rememberTraceAuthority(runnerTrace);
|
|
const error = normalizeAgentError((result as AgentChatResultResponse).error ?? runnerTrace?.error ?? message.error);
|
|
const errorText = agentErrorDisplayText(error);
|
|
const agentRun = agentRunFromResult(result as AgentChatResultResponse, runnerTrace) ?? agentRunFromMessage(message);
|
|
const replyText = agentReplyText((result as AgentChatResultResponse).reply);
|
|
const traceAssistantText = assistantTextFromTraceEvents(firstArray((result as AgentChatResultResponse).events, (result as AgentChatResultResponse).traceEvents, runnerTrace?.events));
|
|
const text = terminal
|
|
? 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> {
|
|
const value = text.trim();
|
|
if (!value) return;
|
|
beginWorkspaceSelection();
|
|
if (!composer.value.sessionId) {
|
|
messages.value.push(makeMessage("system", "session_required:请先显式新建或选择 Code Agent session。", "blocked", { title: "需要 Session" }));
|
|
return;
|
|
}
|
|
const steerMode = composer.value.submitMode === "steer";
|
|
const submitEntry = steerMode ? "steer" : activeConversationId.value ? "existing" : "new";
|
|
const traceId = steerMode && composer.value.targetTraceId ? composer.value.targetTraceId : nextProtocolId("trc");
|
|
const steerTraceId = steerMode ? nextProtocolId("trc_steer") : null;
|
|
const conversationId = activeConversationId.value ?? nextProtocolId("cnv");
|
|
setActiveConversationSelection(conversationId);
|
|
const sessionId = composer.value.sessionId;
|
|
const threadId = composer.value.threadId;
|
|
const providerThreadId = providerThreadIdForRequest(threadId);
|
|
const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, conversationId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" });
|
|
const pending = makeMessage("agent", "", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running" });
|
|
messages.value.push(user, pending);
|
|
startWorkbenchSubmitJourney({ traceId, conversationId, entry: submitEntry, backend: providerProfile.value, transport: "sse" });
|
|
void refreshSessionStatusById(sessionId);
|
|
chatPending.value = true;
|
|
currentRequest.value = { traceId, conversationId, sessionId, threadId, status: "running" };
|
|
rememberRecentDraft(value);
|
|
recordActivity("submit");
|
|
const sessionCreate = await waitForPendingSessionCreate(conversationId);
|
|
if (!sessionCreate.ok) {
|
|
markMessage(traceId, { status: "failed", text: `新会话尚未准备好,消息未提交:${sessionCreate.error ?? "session create failed"}` });
|
|
failWorkbenchSubmitJourney(traceId, "error");
|
|
chatPending.value = false;
|
|
currentRequest.value = null;
|
|
return;
|
|
}
|
|
const payload = { projectId: activeProjectId.value, message: value, prompt: value, conversationId, sessionId, threadId: providerThreadId, traceId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, workspaceId: workspace.value?.workspaceId, workspaceRevision: workspace.value?.revision, targetTraceId: composer.value.targetTraceId, steerTraceId };
|
|
const route = steerMode ? api.agent.steerAgentMessage : api.agent.sendAgentMessage;
|
|
const response = await route(payload, codeAgentTimeoutMs.value, () => activityRef.value);
|
|
if (!response.ok || !response.data) {
|
|
markMessage(traceId, { status: "failed", text: response.error ?? "Code Agent 请求失败" });
|
|
failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error");
|
|
if (steerMode && response.status === 404) void clearActiveTrace(traceId, "steer-trace-not-found");
|
|
chatPending.value = false;
|
|
currentRequest.value = null;
|
|
void refreshSessionStatusById(sessionId);
|
|
return;
|
|
}
|
|
markWorkbenchSubmitApiAccepted(traceId);
|
|
void refreshSessionStatusById(sessionId);
|
|
alignOptimisticTurnMessages(traceId, response.data);
|
|
applyTurnStatusSnapshot(traceId, response.data);
|
|
void refreshConversations(conversationId);
|
|
if ((response.data as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(response.data.status)) {
|
|
completeTrace(traceId, response.data as AgentChatResultResponse);
|
|
return;
|
|
}
|
|
restartRealtime("submit");
|
|
scheduleRealtimeGapHydration("submit");
|
|
}
|
|
|
|
async function cancelAgentMessage(message: ChatMessage): Promise<void> {
|
|
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
|
if (!traceId) return;
|
|
const response = await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value, conversationId: message.conversationId ?? activeConversationId.value });
|
|
const canceledStatus = workspaceSessionStatusFromChatStatus(firstNonEmptyString((response.data as Record<string, unknown> | null)?.status, "canceled"));
|
|
applyTurnStatusSnapshot(traceId, { traceId, status: firstNonEmptyString((response.data as Record<string, unknown> | null)?.status, "canceled"), running: false, terminal: true, sessionId: message.sessionId ?? selectedSessionId.value ?? undefined, threadId: message.threadId ?? selectedThreadId.value ?? undefined, conversationId: message.conversationId ?? activeConversationId.value ?? undefined } as AgentChatResultResponse);
|
|
markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" });
|
|
void clearActiveTrace(traceId, "cancel-agent-message", canceledStatus);
|
|
if (message.status === "running") chatPending.value = false;
|
|
currentRequest.value = null;
|
|
void refreshSessionStatusById(message.sessionId ?? selectedSessionId.value);
|
|
}
|
|
|
|
async function cancelRunningTrace(): Promise<void> {
|
|
const target = composer.value;
|
|
const message = resolveCancelableAgentMessage({
|
|
messages: messages.value,
|
|
activeConversationId: activeConversationId.value,
|
|
targetTraceId: target.targetTraceId,
|
|
targetSessionId: target.sessionId,
|
|
targetThreadId: target.threadId,
|
|
turnStatusAuthority: turnStatusAuthority.value
|
|
});
|
|
if (message) await cancelAgentMessage(message);
|
|
}
|
|
|
|
async function retryAgentMessage(message: ChatMessage): Promise<void> {
|
|
const retryInput = firstNonEmptyString(message.retryInput, messages.value.find((item) => item.role === "user" && item.traceId === message.traceId)?.text);
|
|
if (!retryInput) {
|
|
return;
|
|
}
|
|
await submitMessage(retryInput);
|
|
}
|
|
|
|
async function hydrateTraceEventsForMessage(message: ChatMessage): Promise<void> {
|
|
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
|
if (!traceId) return;
|
|
if (traceHydrationInFlight.has(traceId)) return;
|
|
traceHydrationInFlight.add(traceId);
|
|
try {
|
|
let sinceSeq = traceHydrationSinceSeq(message.runnerTrace);
|
|
for (let page = 0; page < TRACE_HYDRATION_MAX_PAGES; page += 1) {
|
|
const result = await fetchTraceHydrationPage(traceId, sinceSeq);
|
|
if (!result.ok || !result.data) return;
|
|
applyTraceHydrationResult(traceId, result.data);
|
|
const nextSinceSeq = traceNextSinceSeq(result.data, sinceSeq);
|
|
if (result.data.hasMore !== true || nextSinceSeq <= sinceSeq) return;
|
|
sinceSeq = nextSinceSeq;
|
|
}
|
|
} finally {
|
|
traceHydrationInFlight.delete(traceId);
|
|
}
|
|
}
|
|
|
|
async function fetchTraceHydrationPage(traceId: string, sinceSeq: number): Promise<ApiResult<AgentChatResultResponse>> {
|
|
let lastResult: ApiResult<AgentChatResultResponse> | null = null;
|
|
for (let attempt = 0; attempt < TRACE_HYDRATION_MAX_ATTEMPTS; attempt += 1) {
|
|
const result = await api.agent.getAgentTrace(traceId, activeProjectId.value, codeAgentTimeoutMs.value, () => activityRef.value, { sinceSeq, limit: TRACE_HYDRATION_PAGE_LIMIT });
|
|
if (result.ok && result.data) return result;
|
|
lastResult = result;
|
|
if (attempt < TRACE_HYDRATION_MAX_ATTEMPTS - 1) {
|
|
await delayTraceHydrationRetry(TRACE_HYDRATION_RETRY_DELAY_MS * (attempt + 1));
|
|
}
|
|
}
|
|
return lastResult ?? { ok: false, status: 0, data: null, error: "trace_hydration_failed" };
|
|
}
|
|
|
|
function delayTraceHydrationRetry(ms: number): Promise<void> {
|
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function hydrateTraceEvents(source: ChatMessage[] = messages.value): Promise<void> {
|
|
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);
|
|
}
|
|
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;
|
|
if (!authority) return message;
|
|
return { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, authority) };
|
|
});
|
|
}
|
|
|
|
function rememberTraceAuthority(trace: ChatMessage["runnerTrace"]): void {
|
|
const traceId = firstNonEmptyString(trace?.traceId);
|
|
if (!traceId || !trace) return;
|
|
const existing = traceAuthorityById.value[traceId] ?? null;
|
|
if (trace.eventSource !== "trace-api" && !traceHasEvents(trace) && !existing) return;
|
|
const nextTrace = mergeRunnerTrace(existing, trace as NonNullable<ChatMessage["runnerTrace"]>);
|
|
reduceServerState({ type: "trace.snapshot", traceId, trace: nextTrace });
|
|
}
|
|
|
|
function applyTraceHydrationResult(traceId: string, result: AgentChatResultResponse): void {
|
|
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) => {
|
|
if (firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== traceId || message.role !== "agent") return message;
|
|
const runnerTrace = mergeRunnerTrace(message.runnerTrace, {
|
|
...(result.runnerTrace ?? {}),
|
|
traceId: result.traceId ?? traceId,
|
|
status: result.status ?? result.traceStatus ?? message.runnerTrace?.status,
|
|
sessionId: result.sessionId ?? message.runnerTrace?.sessionId,
|
|
threadId: result.threadId ?? message.runnerTrace?.threadId,
|
|
events,
|
|
eventSource: "trace-api",
|
|
eventCount: result.eventCount ?? events.length,
|
|
eventsCompacted: result.runnerTrace?.eventsCompacted ?? false,
|
|
fullTraceLoaded: result.fullTraceLoaded === true || result.hasMore === false,
|
|
hasMore: result.hasMore,
|
|
truncated: result.truncated,
|
|
nextSinceSeq: typeof result.nextSinceSeq === "number" ? result.nextSinceSeq : null,
|
|
range: result.range,
|
|
traceStatus: result.traceStatus,
|
|
retention: result.retention,
|
|
terminalEvidence: result.terminalEvidence,
|
|
finalResponse: result.finalResponse,
|
|
traceSummary: result.traceSummary,
|
|
agentRun: result.agentRun,
|
|
lastEventLabel: result.lastEventLabel ?? events.at(-1)?.label ?? events.at(-1)?.type,
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
const explicitStatus = normalizedStatusText(result.status);
|
|
const status = explicitStatus ? statusFromResult(result.status) : message.status;
|
|
const terminal = result.terminal === true || isTerminalMessageStatus(status);
|
|
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
|
|
const errorText = agentErrorDisplayText(error);
|
|
const traceAssistantText = assistantTextFromTraceEvents(events);
|
|
const text = terminal ? firstNonEmptyString(result.assistantText, finalResponseText(result.finalResponse), agentReplyText(result.reply), errorText, result.text, result.summary, traceAssistantText, message.text) ?? message.text : message.text;
|
|
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);
|
|
}
|
|
|
|
function setProviderProfile(value: ProviderProfile): void {
|
|
providerProfile.value = value;
|
|
localStorage.setItem("hwlab.workbench.providerProfile.v1", value);
|
|
if (workspace.value?.workspaceId) {
|
|
void api.workbench.updateWorkspace(workspace.value.workspaceId, { projectId: activeProjectId.value, providerProfile: value, updatedByClient: "cloud-web-vue" });
|
|
}
|
|
}
|
|
|
|
async function refreshProviderOptions(): Promise<void> {
|
|
const response = await api.providerProfiles.catalog();
|
|
providerOptions.value = response.ok ? providerProfileOptionsFromPayload(response.data, providerProfile.value) : defaultProviderProfileOptions(providerProfile.value);
|
|
}
|
|
|
|
function rememberRecentDraft(text: string): void {
|
|
recentDrafts.value = recordRecentDraft(recentDrafts.value, text);
|
|
writeRecentDrafts(recentDrafts.value);
|
|
}
|
|
|
|
function pickDraft(draft: DraftEntry | string): string {
|
|
return typeof draft === "string" ? draft : draft.text;
|
|
}
|
|
|
|
function clearRecentDrafts(): void {
|
|
recentDrafts.value = [];
|
|
try { localStorage.removeItem(RECENT_DRAFTS_STORAGE_KEY); } catch { /* ignore */ }
|
|
}
|
|
|
|
function clearConversation(): void {
|
|
messages.value = [];
|
|
currentRequest.value = null;
|
|
const activeTraceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value);
|
|
if (activeTraceId) void clearActiveTrace(activeTraceId, "clear-conversation");
|
|
restartRealtime("clear-conversation");
|
|
}
|
|
|
|
function reattachTrace(traceId: string): void {
|
|
const initial: AgentChatResponse = { status: "running", traceId, turnUrl: `/v1/agent/turns/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}` };
|
|
if (!messages.value.some((message) => message.traceId === traceId)) messages.value.push(makeMessage("agent", "", "running", { traceId, title: "Code Agent", traceAutoLifecycle: "running" }));
|
|
currentRequest.value = { traceId, conversationId: activeConversationId.value ?? null, sessionId: selectedSessionId.value ?? null, threadId: selectedThreadId.value ?? null, status: initial.status };
|
|
restartRealtime("reattach");
|
|
scheduleRealtimeGapHydration("reattach");
|
|
}
|
|
|
|
async function validateAndReattachTrace(traceId: string): Promise<void> {
|
|
const result = await api.agent.getAgentTurn(traceId, activeProjectId.value, 8000, () => activityRef.value);
|
|
if (!result.ok || !result.data) {
|
|
await clearActiveTrace(traceId, result.status === 404 ? "reattach-result-not-found" : "reattach-result-unavailable");
|
|
return;
|
|
}
|
|
applyTurnStatusSnapshot(traceId, result.data);
|
|
if (result.data.running === true || isTraceActiveStatus(result.data.status)) {
|
|
reattachTrace(traceId);
|
|
return;
|
|
}
|
|
await clearActiveTrace(traceId, "reattach-terminal-result", workspaceSessionStatusFromChatStatus(statusFromResult(result.data.status)));
|
|
}
|
|
|
|
function restartRealtime(reason: string): void {
|
|
const traceId = realtimeTraceId();
|
|
const workspaceId = workspace.value?.workspaceId ?? null;
|
|
const conversationId = activeConversationId.value ?? null;
|
|
const sessionId = selectedSessionId.value ?? null;
|
|
const key = [activeProjectId.value, workspaceId ?? "", conversationId ?? "", sessionId ?? "", traceId ?? ""].join("|");
|
|
if (key === realtimeKey && realtimeStream) return;
|
|
stopRealtime();
|
|
realtimeKey = key;
|
|
if (!workspaceId && !traceId) return;
|
|
realtimeStream = connectWorkbenchEvents({
|
|
projectId: activeProjectId.value,
|
|
workspaceId,
|
|
conversationId,
|
|
sessionId,
|
|
traceId,
|
|
onOpen: () => scheduleRealtimeGapHydration(`${reason}:open`),
|
|
onError: () => scheduleRealtimeGapHydration(`${reason}:error`),
|
|
onEvent: (event, eventName) => applyRealtimeEvent(event, eventName)
|
|
});
|
|
if (!realtimeStream) scheduleRealtimeGapHydration(`${reason}:eventsource-unavailable`);
|
|
}
|
|
|
|
function stopRealtime(): void {
|
|
realtimeStream?.close();
|
|
realtimeStream = null;
|
|
realtimeKey = "";
|
|
}
|
|
|
|
function realtimeTraceId(): string | null {
|
|
return firstNonEmptyString(currentRequest.value?.traceId, activeTraceIdFromMessages(messages.value, turnStatusAuthority.value));
|
|
}
|
|
|
|
function applyRealtimeEvent(event: WorkbenchRealtimeEvent, eventName: string): void {
|
|
recordActivity(event.type ? `realtime:${event.type}` : `realtime:${eventName}`);
|
|
if (event.type === "workspace.snapshot" && event.workspace) {
|
|
applyRealtimeWorkspaceSnapshot(event.workspace);
|
|
return;
|
|
}
|
|
if (event.type === "trace.snapshot") {
|
|
applyRealtimeTraceSnapshot(event.traceId, event.snapshot);
|
|
return;
|
|
}
|
|
if (event.type === "trace.event") {
|
|
applyRealtimeTraceEvent(event.traceId, event.event, event.snapshot, event);
|
|
return;
|
|
}
|
|
if (event.type === "turn.snapshot" && event.turn) {
|
|
applyRealtimeTurnSnapshot(event.turn);
|
|
return;
|
|
}
|
|
if (event.type === "trace.unavailable") {
|
|
const traceId = firstNonEmptyString(event.traceId);
|
|
if (traceId) void clearActiveTrace(traceId, "realtime-trace-unavailable");
|
|
}
|
|
}
|
|
|
|
function applyRealtimeWorkspaceSnapshot(nextWorkspace: WorkspaceRecord): void {
|
|
if (!workspaceSnapshotTargetsActiveConversation(nextWorkspace)) return;
|
|
const previousTraceId = realtimeTraceId();
|
|
workspace.value = nextWorkspace;
|
|
rememberWorkspaceSnapshot(workspaceProjectId(nextWorkspace, activeProjectId.value), nextWorkspace);
|
|
providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value;
|
|
rememberWorkbenchProjectId(workspaceProjectId(workspace.value, activeProjectId.value));
|
|
const nextTraceId = realtimeTraceId();
|
|
if (firstNonEmptyString(previousTraceId) !== firstNonEmptyString(nextTraceId)) {
|
|
restartRealtime("workspace-snapshot");
|
|
scheduleRealtimeGapHydration("workspace-snapshot");
|
|
}
|
|
}
|
|
|
|
function applyRealtimeTraceSnapshot(traceId: string | null | undefined, snapshot: WorkbenchRealtimeEvent["snapshot"]): void {
|
|
const id = firstNonEmptyString(traceId, snapshot?.traceId);
|
|
if (!id || !snapshot) return;
|
|
applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, snapshot));
|
|
if (isTerminalMessageStatus(snapshot.status)) void refreshTerminalTraceFromRest(id, "realtime-trace-snapshot");
|
|
}
|
|
|
|
function applyRealtimeTraceEvent(traceId: string | null | undefined, event: WorkbenchRealtimeEvent["event"], snapshot: WorkbenchRealtimeEvent["snapshot"], realtimeEvent?: WorkbenchRealtimeEvent | null): void {
|
|
const id = firstNonEmptyString(traceId, event?.traceId, snapshot?.traceId);
|
|
if (!id) return;
|
|
const events = event ? [event] : Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
markWorkbenchTraceEventsReceived({ traceId: id, events, transport: "sse", serverSentAt: realtimeEvent?.serverSentAt, eventCreatedAt: realtimeEvent?.eventCreatedAt, traceSeq: realtimeEvent?.traceSeq ?? realtimeEvent?.cursor?.traceSeq });
|
|
applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, snapshot ?? { traceId: id, status: event?.status, events }, events));
|
|
if (event?.terminal === true || isTerminalMessageStatus(event?.status) || isTerminalMessageStatus(snapshot?.status)) void refreshTerminalTraceFromRest(id, "realtime-trace-event");
|
|
}
|
|
|
|
function applyRealtimeTurnSnapshot(turn: Record<string, unknown>): void {
|
|
const traceId = firstNonEmptyString(turn.traceId);
|
|
if (!traceId) return;
|
|
const status = firstNonEmptyString(turn.status) ?? undefined;
|
|
applyTurnStatusSnapshot(traceId, {
|
|
traceId,
|
|
status,
|
|
running: turn.running === true,
|
|
terminal: turn.terminal === true,
|
|
conversationId: firstNonEmptyString(turn.conversationId) ?? undefined,
|
|
sessionId: firstNonEmptyString(turn.sessionId) ?? undefined,
|
|
threadId: firstNonEmptyString(turn.threadId) ?? undefined,
|
|
agentRun: turn.agentRun as AgentRunProvenance | undefined
|
|
} as AgentChatResultResponse);
|
|
if (turn.terminal === true || isTerminalMessageStatus(status)) void refreshTerminalTraceFromRest(traceId, "realtime-turn-snapshot");
|
|
}
|
|
|
|
async function refreshTerminalTraceFromRest(traceId: string, reason: string): Promise<void> {
|
|
if (terminalRealtimeRefreshInFlight.has(traceId)) return;
|
|
terminalRealtimeRefreshInFlight.add(traceId);
|
|
try {
|
|
const result = await api.agent.getAgentTurn(traceId, activeProjectId.value, 8000, () => activityRef.value);
|
|
if (!result.ok || !result.data) return;
|
|
applyTurnStatusSnapshot(traceId, result.data);
|
|
if (result.data.terminal === true || isTerminalMessageStatus(result.data.status)) {
|
|
completeTrace(traceId, result.data);
|
|
}
|
|
} finally {
|
|
terminalRealtimeRefreshInFlight.delete(traceId);
|
|
restartRealtime(reason);
|
|
}
|
|
}
|
|
|
|
function scheduleRealtimeGapHydration(reason: string): void {
|
|
if (typeof window === "undefined") {
|
|
void hydrateRealtimeGap(reason);
|
|
return;
|
|
}
|
|
if (realtimeGapTimer) window.clearTimeout(realtimeGapTimer);
|
|
realtimeGapTimer = window.setTimeout(() => {
|
|
realtimeGapTimer = null;
|
|
void hydrateRealtimeGap(reason);
|
|
}, 200);
|
|
}
|
|
|
|
async function hydrateRealtimeGap(reason: string): Promise<void> {
|
|
recordActivity(`realtime-gap:${reason}`);
|
|
const [workspaceResult] = await Promise.all([
|
|
api.workbench.workspace(activeProjectId.value),
|
|
hydrateTurnStatusAuthority(messages.value),
|
|
hydrateTraceEvents(messages.value)
|
|
]);
|
|
if (workspaceResult.ok && workspaceResult.data?.workspace) applyRealtimeWorkspaceSnapshot(workspaceResult.data.workspace);
|
|
restartRealtime(`gap:${reason}`);
|
|
}
|
|
|
|
function installRealtimeVisibilityHandler(): void {
|
|
if (typeof document === "undefined") return;
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState !== "visible") return;
|
|
restartRealtime("visibility");
|
|
scheduleRealtimeGapHydration("visibility");
|
|
});
|
|
}
|
|
|
|
function applyTraceSnapshot(traceId: string, snapshot: TraceSnapshot): void {
|
|
const trace = snapshotToRunnerTrace(snapshot);
|
|
rememberTurnStatus(traceId, snapshot);
|
|
const status = statusFromResult(snapshot.status);
|
|
const terminal = isTerminalMessageStatus(status);
|
|
messages.value = messages.value.map((message) => {
|
|
if (message.traceId !== traceId) return message;
|
|
const runnerTrace = mergeRunnerTrace(message.runnerTrace, trace);
|
|
rememberTraceAuthority(runnerTrace);
|
|
const traceAssistantText = message.role === "agent" ? assistantTextFromTraceEvents(Array.isArray(runnerTrace.events) ? runnerTrace.events : []) : null;
|
|
const error = message.role === "agent" ? normalizeAgentError(runnerTrace.error ?? message.error) : normalizeAgentError(message.error);
|
|
const errorText = message.role === "agent" ? agentErrorDisplayText(error) : null;
|
|
const nextText = message.role === "agent" && terminal
|
|
? firstNonEmptyString(finalResponseText(runnerTrace.finalResponse), traceAssistantText, errorText, message.text) ?? message.text
|
|
: message.role === "agent" && !firstNonEmptyString(message.text)
|
|
? 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 refreshSessionStatusById(trace.sessionId ?? selectedSessionId.value);
|
|
}
|
|
|
|
function completeTrace(traceId: string, result: AgentChatResultResponse): void {
|
|
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) => {
|
|
if (message.traceId !== traceId || message.role !== "agent") 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);
|
|
void hydrateTraceEvents(messages.value);
|
|
chatPending.value = false;
|
|
currentRequest.value = null;
|
|
void clearActiveTrace(traceId, "trace-terminal", workspaceSessionStatusFromChatStatus(terminalStatus));
|
|
void refreshSessionStatusById(result.sessionId ?? selectedSessionId.value);
|
|
void refreshConversations(firstNonEmptyString((result as Record<string, unknown>).conversationId, activeConversationId.value));
|
|
restartRealtime("trace-terminal");
|
|
}
|
|
|
|
async function hydrateTerminalMessageDiagnostics(): Promise<void> {
|
|
const targets = messages.value.filter(messageNeedsTerminalDiagnostics).slice(-6);
|
|
await Promise.all(targets.map(async (message) => {
|
|
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
|
if (!traceId) return;
|
|
const result = await api.agent.getAgentTurn(traceId, activeProjectId.value, 8000, () => activityRef.value);
|
|
if (!result.ok || !result.data) return;
|
|
applyTurnStatusSnapshot(traceId, result.data);
|
|
}));
|
|
void hydrateTraceEvents(messages.value);
|
|
}
|
|
|
|
function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void {
|
|
messages.value = messages.value.map((message) => {
|
|
if (message.traceId !== traceId || message.role !== "agent") 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);
|
|
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 refreshSessionStatusById(result.sessionId ?? selectedSessionId.value);
|
|
void refreshConversations(firstNonEmptyString((result as Record<string, unknown>).conversationId, activeConversationId.value));
|
|
}
|
|
|
|
function failTrace(traceId: string, message: string): void {
|
|
markMessage(traceId, { status: "failed", text: message });
|
|
chatPending.value = false;
|
|
currentRequest.value = null;
|
|
void clearActiveTrace(traceId, "trace-infrastructure-error", "failed");
|
|
void refreshSelectedSessionStatus();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function alignOptimisticTurnMessages(traceId: string, lifecycle: AgentChatResponse): void {
|
|
const turnId = firstNonEmptyString(lifecycle.turnId, traceId);
|
|
const userMessageId = firstNonEmptyString(lifecycle.userMessageId);
|
|
const assistantMessageId = firstNonEmptyString(lifecycle.assistantMessageId);
|
|
if (!turnId && !userMessageId && !assistantMessageId) return;
|
|
messages.value = messages.value.map((message) => {
|
|
if (message.traceId !== traceId) return message;
|
|
if (message.role === "user" && userMessageId) return { ...message, id: userMessageId, messageId: userMessageId, turnId, updatedAt: new Date().toISOString() };
|
|
if (message.role === "agent" && assistantMessageId) return { ...message, id: assistantMessageId, messageId: assistantMessageId, turnId, updatedAt: new Date().toISOString() };
|
|
return turnId ? { ...message, turnId, updatedAt: new Date().toISOString() } : message;
|
|
});
|
|
}
|
|
|
|
async function ensureWorkspace(): Promise<WorkspaceRecord | null> {
|
|
if (workspace.value) return workspace.value;
|
|
await hydrate();
|
|
return workspace.value;
|
|
}
|
|
|
|
async function clearActiveTrace(traceId: string, reason: string, sessionStatus?: string | null): Promise<void> {
|
|
const requestEpoch = workspaceSelectionEpoch.value;
|
|
const current = workspaceWithClearedActiveTrace(workspace.value, traceId, reason, sessionStatus);
|
|
if (current === workspace.value) return;
|
|
workspace.value = current;
|
|
if (!current?.workspaceId) return;
|
|
const response = await api.workbench.updateWorkspace(current.workspaceId, {
|
|
projectId: activeProjectId.value,
|
|
activeTraceId: null,
|
|
lastTraceId: null,
|
|
staleActiveTraceId: traceId,
|
|
staleActiveTraceReason: reason,
|
|
sessionStatus: current.workspace?.sessionStatus,
|
|
workspace: current.workspace,
|
|
updatedByClient: "cloud-web-vue-active-trace-repair"
|
|
});
|
|
if (response.ok && response.data?.workspace && shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: response.data.workspace })) {
|
|
workspace.value = response.data.workspace;
|
|
rememberWorkspaceSnapshot(workspaceProjectId(workspace.value, activeProjectId.value), workspace.value);
|
|
}
|
|
}
|
|
|
|
function beginWorkspaceSelection(): number {
|
|
workspaceSelectionEpoch.value += 1;
|
|
return workspaceSelectionEpoch.value;
|
|
}
|
|
|
|
function isCurrentWorkspaceSelection(epoch: number, conversationId?: string | null): boolean {
|
|
if (epoch !== workspaceSelectionEpoch.value) return false;
|
|
if (!conversationId) return true;
|
|
return activeConversationId.value === conversationId || switchingConversationId.value === conversationId;
|
|
}
|
|
|
|
function clearSwitchingConversation(conversationId: string): void {
|
|
if (switchingConversationId.value === conversationId) switchingConversationId.value = null;
|
|
}
|
|
|
|
function clearConversationDetailLoading(conversationId: string | null | undefined): void {
|
|
if (!conversationId || conversationDetailLoadingId.value === conversationId) conversationDetailLoadingId.value = null;
|
|
}
|
|
|
|
function applySelectedConversationDetail(conversation: ConversationRecord, baseWorkspace: WorkspaceRecord | null, selectedProjectId: string, previousConversationId: string | null, previousMessages: ChatMessage[]): void {
|
|
setActiveConversationSelection(conversation.conversationId);
|
|
workspace.value = workspaceWithSelectedConversation(baseWorkspace, conversation, selectedProjectId);
|
|
rememberWorkspaceSnapshot(selectedProjectId, workspace.value);
|
|
rememberConversationDetail(conversation);
|
|
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(conversation, conversation.conversationId, previousConversationId, previousMessages), previousMessages);
|
|
conversations.value = mergeConversationIntoList(conversations.value, conversation);
|
|
conversationsReady.value = true;
|
|
currentRequest.value = null;
|
|
void hydrateTurnStatusAuthority(messages.value);
|
|
void hydrateTerminalMessageDiagnostics();
|
|
void hydrateTraceEvents(messages.value);
|
|
reattachRestoredActiveTrace();
|
|
restartRealtime("apply-selected-conversation");
|
|
}
|
|
|
|
function isolateConversationLoadFailure(conversationId: string, previousConversationId: string | null, previousMessages: ChatMessage[], message: string): void {
|
|
const targetId = normalizeWorkbenchConversationId(conversationId);
|
|
const previousId = normalizeWorkbenchConversationId(previousConversationId);
|
|
if (targetId) conversations.value = conversations.value.filter((item) => item.conversationId !== targetId || (item.messages?.length ?? 0) > 0);
|
|
if (previousId && previousId !== targetId) {
|
|
const previousConversation = conversations.value.find((item) => item.conversationId === previousId) ?? null;
|
|
replaceActiveConversationSelection(previousId);
|
|
if (previousConversation) {
|
|
const previousProjectId = conversationProjectId(previousConversation, activeProjectId.value);
|
|
workspace.value = workspaceWithSelectedConversation(workspace.value, previousConversation, previousProjectId);
|
|
rememberWorkspaceSnapshot(previousProjectId, workspace.value);
|
|
}
|
|
messages.value = previousMessages;
|
|
} else {
|
|
messages.value = [];
|
|
}
|
|
currentRequest.value = null;
|
|
error.value = message;
|
|
conversationsReady.value = conversations.value.length > 0;
|
|
restartRealtime("conversation-load-failed");
|
|
}
|
|
|
|
function workspaceSnapshotTargetsActiveConversation(nextWorkspace: WorkspaceRecord): boolean {
|
|
const currentConversationId = activeConversationId.value;
|
|
if (!currentConversationId) return true;
|
|
return selectedConversationIdFromWorkspace(nextWorkspace) === currentConversationId;
|
|
}
|
|
|
|
async function persistSelectedConversation(conversation: ConversationRecord, selectedProjectId: string, existingResponse?: ApiResult<{ workspace?: WorkspaceRecord }>): Promise<void> {
|
|
const conversationId = conversation.conversationId;
|
|
let response = existingResponse ?? null;
|
|
if (!response) {
|
|
const workspaceId = workspace.value?.workspaceId;
|
|
if (!workspaceId) return;
|
|
response = await api.workbench.selectConversation(workspaceId, { projectId: selectedProjectId, conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue" });
|
|
}
|
|
if (!response.ok && response.status === 404) {
|
|
const fresh = await api.workbench.workspace(selectedProjectId);
|
|
const freshWorkspace = fresh.data?.workspace;
|
|
if (fresh.ok && freshWorkspace?.workspaceId && activeConversationId.value === conversationId) {
|
|
response = await api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: selectedProjectId, conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue-retry" });
|
|
}
|
|
}
|
|
if (!response.ok || !response.data?.workspace || activeConversationId.value !== conversationId) return;
|
|
workspace.value = workspaceWithSelectedConversation(response.data.workspace, conversation, selectedProjectId);
|
|
rememberWorkspaceSnapshot(selectedProjectId, workspace.value);
|
|
rememberConversationDetail(conversation);
|
|
}
|
|
|
|
async function retrySelectConversationWithFreshWorkspace(conversation: ConversationRecord, tabProjectId: string, requestEpoch: number): Promise<boolean> {
|
|
conversationDetailLoadingId.value = conversation.conversationId;
|
|
const fresh = await api.workbench.workspace(tabProjectId);
|
|
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) {
|
|
clearConversationDetailLoading(conversation.conversationId);
|
|
return true;
|
|
}
|
|
const freshWorkspace = fresh.data?.workspace;
|
|
if (!fresh.ok || !freshWorkspace?.workspaceId) return false;
|
|
setActiveConversationSelection(conversation.conversationId);
|
|
workspace.value = optimisticWorkspaceSelection(freshWorkspace, conversation, tabProjectId);
|
|
rememberWorkspaceSnapshot(tabProjectId, workspace.value);
|
|
rememberConversationDetail(conversation);
|
|
const [retried, detailResponse] = await Promise.all([
|
|
api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue-retry" }),
|
|
api.workbench.conversation(conversation.conversationId, { projectId: tabProjectId })
|
|
]);
|
|
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) {
|
|
clearConversationDetailLoading(conversation.conversationId);
|
|
return true;
|
|
}
|
|
clearConversationDetailLoading(conversation.conversationId);
|
|
if (!retried.ok) {
|
|
error.value = retried.error ?? "session switch failed";
|
|
return false;
|
|
}
|
|
const selectedConversation = detailResponse.ok && !isArchivedConversation(detailResponse.data?.conversation) ? detailResponse.data?.conversation ?? null : null;
|
|
if (!selectedConversation) {
|
|
error.value = isArchivedConversation(detailResponse.data?.conversation) ? "session archived" : detailResponse.error ?? "conversation unavailable";
|
|
return false;
|
|
}
|
|
workspace.value = workspaceWithSelectedConversation(retried.data?.workspace ?? workspace.value, selectedConversation, conversationProjectId(selectedConversation, tabProjectId));
|
|
rememberWorkspaceSnapshot(tabProjectId, workspace.value);
|
|
rememberConversationDetail(selectedConversation);
|
|
messages.value = messagesFromSelectedConversation(selectedConversation, conversation.conversationId, null, []);
|
|
conversations.value = mergeConversationIntoList(conversations.value, selectedConversation);
|
|
rememberConversationDetail(selectedConversation);
|
|
void hydrateTurnStatusAuthority(messages.value);
|
|
void hydrateTerminalMessageDiagnostics();
|
|
void hydrateTraceEvents(messages.value);
|
|
reattachRestoredActiveTrace();
|
|
currentRequest.value = null;
|
|
await refreshSelectedSessionStatus(conversation);
|
|
await refreshConversations(conversation.conversationId);
|
|
finishWorkbenchSessionSwitchFullLoad(conversation.conversationId, selectedConversation ? "ok" : "partial");
|
|
return true;
|
|
}
|
|
|
|
function currentListIncludeConversationId(): string | null {
|
|
return firstNonEmptyString(switchingConversationId.value, activeConversationId.value, selectedConversationIdFromWorkspace(workspace.value));
|
|
}
|
|
|
|
function reattachRestoredActiveTrace(): void {
|
|
void hydrateTraceEvents(messages.value);
|
|
const traceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value);
|
|
if (traceId) void validateAndReattachTrace(traceId);
|
|
}
|
|
|
|
installRealtimeVisibilityHandler();
|
|
|
|
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, conversationDetailLoading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
|
|
});
|
|
|
|
function sessionIdFromConversation(conversation: ConversationRecord | null | undefined): string | null {
|
|
return firstNonEmptyString(conversation?.sessionId, conversation?.session?.sessionId) ?? null;
|
|
}
|
|
|
|
function providerThreadIdForRequest(threadId: string | null | undefined): string | null {
|
|
const value = firstNonEmptyString(threadId) ?? null;
|
|
if (!value) return null;
|
|
return /^thr_[A-Za-z0-9]{12,32}$/u.test(value) ? null : value;
|
|
}
|
|
|
|
function makeOptimisticConversation(projectId: string, now = new Date()): ConversationRecord {
|
|
const token = nextProtocolId("cnv").slice(4);
|
|
const title = `新会话 ${shortSessionTimestamp(now)}`;
|
|
const createdAt = now.toISOString();
|
|
const sessionId = `ses_${token}`;
|
|
return {
|
|
conversationId: `cnv_${token}`,
|
|
projectId,
|
|
sessionId,
|
|
threadId: null,
|
|
status: "active",
|
|
title,
|
|
name: title,
|
|
startedAt: createdAt,
|
|
updatedAt: createdAt,
|
|
messageCount: 0,
|
|
session: { sessionId, status: "active" },
|
|
messages: []
|
|
};
|
|
}
|
|
|
|
function shortSessionTimestamp(value: Date): string {
|
|
const pad = (number: number): string => String(number).padStart(2, "0");
|
|
return `${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
|
}
|
|
|
|
function uniqueSessionIds(conversations: ConversationRecord[]): string[] {
|
|
return [...new Set(conversations.map((conversation) => sessionIdFromConversation(conversation)).filter((sessionId): sessionId is string => Boolean(sessionId)))];
|
|
}
|
|
|
|
function uniqueTraceIds(messages: ChatMessage[]): string[] {
|
|
return [...new Set(messages.map((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId)).filter((traceId): traceId is string => Boolean(traceId)))];
|
|
}
|
|
|
|
function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: ConversationRecord, projectId: string): WorkspaceRecord {
|
|
const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId, current.selectedAgentSessionId, current.workspace?.selectedAgentSessionId);
|
|
const threadId = firstNonEmptyString(conversation.threadId, conversation.session?.threadId, current.workspace?.threadId);
|
|
return {
|
|
...current,
|
|
projectId,
|
|
selectedConversationId: conversation.conversationId,
|
|
selectedAgentSessionId: sessionId,
|
|
workspace: {
|
|
...(current.workspace ?? {}),
|
|
projectId,
|
|
selectedConversationId: conversation.conversationId,
|
|
selectedAgentSessionId: sessionId,
|
|
threadId
|
|
}
|
|
};
|
|
}
|
|
|
|
function workspaceWithSelectedConversation(current: WorkspaceRecord | null, conversation: ConversationRecord, projectId: string): WorkspaceRecord | null {
|
|
if (!current) return current;
|
|
const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId, current.selectedAgentSessionId, current.workspace?.selectedAgentSessionId);
|
|
const threadId = firstNonEmptyString(conversation.threadId, conversation.session?.threadId, current.workspace?.threadId);
|
|
return {
|
|
...current,
|
|
projectId,
|
|
selectedConversationId: conversation.conversationId,
|
|
selectedAgentSessionId: sessionId,
|
|
selectedConversation: conversation,
|
|
workspace: {
|
|
...(current.workspace ?? {}),
|
|
projectId,
|
|
selectedConversationId: conversation.conversationId,
|
|
selectedAgentSessionId: sessionId,
|
|
threadId
|
|
}
|
|
};
|
|
}
|
|
|
|
function conversationProjectId(conversation: ConversationRecord | null | undefined, fallback: string): string {
|
|
return firstNonEmptyString(conversation?.projectId, fallback) ?? DEFAULT_WORKBENCH_PROJECT_ID;
|
|
}
|
|
|
|
function messagesFromConversation(conversation: ConversationRecord): ChatMessage[] {
|
|
return (conversation.messages ?? []).map((message) => normalizeChatMessage(message));
|
|
}
|
|
|
|
function messagesFromSelectedConversation(conversation: ConversationRecord | null, selectedConversationId: string | null, previousConversationId: string | null, previousMessages: ChatMessage[]): ChatMessage[] {
|
|
if (conversation && (!selectedConversationId || conversation.conversationId === selectedConversationId)) return messagesFromConversation(conversation);
|
|
if (selectedConversationId && previousConversationId === selectedConversationId && previousMessages.length > 0) return previousMessages;
|
|
return [];
|
|
}
|
|
|
|
function normalizeChatMessage(message: ChatMessage): ChatMessage {
|
|
const runnerTrace = normalizeMessageRunnerTrace(message);
|
|
const error = normalizeAgentError(message.error ?? runnerTrace?.error);
|
|
const agentRun = agentRunFromMessage(message) ?? asAgentRun(runnerTrace?.agentRun);
|
|
const status = normalizeChatMessageStatus(message.status);
|
|
const baseText = firstNonEmptyString(message.text, messageText((message as Record<string, unknown>).content), messageText((message as Record<string, unknown>).message));
|
|
const finalText = firstNonEmptyString(finalResponseText((message as Record<string, unknown>).finalResponse), finalResponseText(runnerTrace?.finalResponse));
|
|
const traceAssistantText = assistantTextFromTraceEvents(Array.isArray(runnerTrace?.events) ? runnerTrace.events : []);
|
|
const errorText = agentErrorDisplayText(error);
|
|
const text = message.role === "agent" && isTerminalMessageStatus(status)
|
|
? firstNonEmptyString(finalText, errorText, traceAssistantText, baseText) ?? ""
|
|
: firstNonEmptyString(baseText, finalText, traceAssistantText, errorText) ?? "";
|
|
const messageId = firstNonEmptyString((message as Record<string, unknown>).messageId, message.id) ?? nextProtocolId("msg");
|
|
return { ...message, text, id: messageId, messageId, title: normalizeWorkbenchMessageTitle(message.role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined };
|
|
}
|
|
|
|
function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: Record<string, TurnStatusAuthority>): string | null {
|
|
for (const message of [...messages].reverse()) {
|
|
if (message.role !== "agent") continue;
|
|
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
|
if (!traceId) continue;
|
|
const turn = turnStatusAuthority[traceId];
|
|
if (turn?.running !== true && !isTraceActiveStatus(turn?.status)) continue;
|
|
if (traceId) return traceId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function realtimeSnapshotToTraceSnapshot(traceId: string, snapshot: WorkbenchRealtimeEvent["snapshot"], overrideEvents?: TraceEvent[]): TraceSnapshot {
|
|
const source = snapshot ?? { traceId };
|
|
const events = Array.isArray(overrideEvents) ? overrideEvents : Array.isArray(source.events) ? source.events : [];
|
|
return {
|
|
...source,
|
|
traceId: firstNonEmptyString(source.traceId, traceId) ?? traceId,
|
|
status: firstNonEmptyString(source.status) ?? undefined,
|
|
events,
|
|
eventCount: firstFiniteNumber(source.eventCount, events.length),
|
|
fullTraceLoaded: source.fullTraceLoaded === true,
|
|
hasMore: source.hasMore === true,
|
|
truncated: source.truncated === true,
|
|
nextSinceSeq: firstFiniteNumber(source.nextSinceSeq) ?? null,
|
|
range: recordValue(source.range) as TraceSnapshot["range"],
|
|
agentRun: asAgentRun(source.agentRun) ?? undefined,
|
|
traceStatus: firstNonEmptyString(source.traceStatus) ?? undefined,
|
|
retention: source.retention,
|
|
terminalEvidence: source.terminalEvidence,
|
|
finalResponse: source.finalResponse,
|
|
traceSummary: source.traceSummary,
|
|
error: normalizeAgentError(source.error) ?? undefined,
|
|
lastEventLabel: firstNonEmptyString(source.lastEventLabel, source.lastEvent?.label, source.lastEvent?.type, events.at(-1)?.label, events.at(-1)?.type) ?? undefined,
|
|
eventSource: "trace-api",
|
|
updatedAt: firstNonEmptyString(source.updatedAt) ?? new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
function normalizedStatusText(value: unknown): string | null {
|
|
const text = firstNonEmptyString(value);
|
|
return text ? text.trim().toLowerCase().replace(/_/gu, "-") : null;
|
|
}
|
|
|
|
function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result: AgentChatResultResponse): NonNullable<ChatMessage["runnerTrace"]> {
|
|
const resultTrace = recordValue(result.runnerTrace);
|
|
const events = firstArray(result.events, result.traceEvents, resultTrace?.events, previous?.events);
|
|
const agentRun = asAgentRun(result.agentRun ?? resultTrace?.agentRun ?? previous?.agentRun);
|
|
return mergeRunnerTrace(previous, {
|
|
...resultTrace,
|
|
traceId: firstNonEmptyString(result.traceId, resultTrace?.traceId, previous?.traceId) ?? undefined,
|
|
status: firstNonEmptyString(result.traceStatus, resultTrace?.status, result.status, previous?.status) ?? undefined,
|
|
sessionId: firstNonEmptyString(result.sessionId, resultTrace?.sessionId, previous?.sessionId) ?? undefined,
|
|
threadId: firstNonEmptyString(result.threadId, resultTrace?.threadId, previous?.threadId) ?? undefined,
|
|
events,
|
|
eventCount: firstFiniteNumber(result.eventCount, resultTrace?.eventCount, previous?.eventCount, events.length),
|
|
eventsCompacted: firstBoolean(resultTrace?.eventsCompacted, previous?.eventsCompacted),
|
|
traceStatus: firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus, previous?.traceStatus) ?? undefined,
|
|
retention: result.retention ?? resultTrace?.retention ?? previous?.retention,
|
|
terminalEvidence: result.terminalEvidence ?? resultTrace?.terminalEvidence ?? previous?.terminalEvidence,
|
|
finalResponse: result.finalResponse ?? resultTrace?.finalResponse ?? previous?.finalResponse,
|
|
traceSummary: result.traceSummary ?? resultTrace?.traceSummary ?? previous?.traceSummary,
|
|
agentRun: agentRun ?? undefined,
|
|
error: normalizeAgentError(result.error ?? resultTrace?.error ?? previous?.error) ?? undefined,
|
|
runnerKind: firstNonEmptyString(agentRun?.adapter, resultTrace?.runnerKind, previous?.runnerKind) ?? undefined,
|
|
sessionMode: firstNonEmptyString(agentRun?.backendProfile, resultTrace?.sessionMode, previous?.sessionMode) ?? undefined,
|
|
lastEventLabel: firstNonEmptyString(result.lastEventLabel, resultTrace?.lastEventLabel, previous?.lastEventLabel, events.at(-1)?.label, events.at(-1)?.type) ?? undefined,
|
|
updatedAt: new Date().toISOString()
|
|
} as NonNullable<ChatMessage["runnerTrace"]>);
|
|
}
|
|
|
|
function normalizeMessageRunnerTrace(message: ChatMessage): ChatMessage["runnerTrace"] {
|
|
const trace = recordValue(message.runnerTrace);
|
|
const agentRun = agentRunFromMessage(message);
|
|
const error = normalizeAgentError(message.error ?? trace?.error);
|
|
if (!trace && !agentRun && !error) return message.runnerTrace ?? null;
|
|
return {
|
|
...(trace ?? {}),
|
|
traceId: message.traceId ?? trace?.traceId,
|
|
sessionId: message.sessionId ?? trace?.sessionId,
|
|
threadId: message.threadId ?? trace?.threadId,
|
|
agentRun: agentRun ?? trace?.agentRun,
|
|
error: error ?? trace?.error,
|
|
runnerKind: agentRun?.adapter ?? trace?.runnerKind,
|
|
sessionMode: agentRun?.backendProfile ?? trace?.sessionMode
|
|
} as ChatMessage["runnerTrace"];
|
|
}
|
|
|
|
function messageNeedsTerminalDiagnostics(message: ChatMessage): boolean {
|
|
if (message.role !== "agent") return false;
|
|
if (!isTerminalMessageStatus(message.status)) return false;
|
|
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
|
if (!traceId) return false;
|
|
const agentRun = agentRunFromMessage(message);
|
|
const error = normalizeAgentError(message.error ?? message.runnerTrace?.error);
|
|
return !agentRun || (message.status !== "completed" && !error);
|
|
}
|
|
|
|
function messageNeedsTraceHydration(message: ChatMessage): boolean {
|
|
if (message.role !== "agent") return false;
|
|
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
|
if (!traceId) return false;
|
|
const trace = message.runnerTrace;
|
|
const events = Array.isArray(trace?.events) ? trace.events : [];
|
|
const eventCount = firstFiniteNumber(trace?.eventCount) ?? events.length;
|
|
if (trace?.fullTraceLoaded === true && trace.eventsCompacted !== true) return events.length === 0 && (eventCount > 0 || isTraceActiveStatus(trace?.status) || isTraceActiveStatus(message.status));
|
|
return events.length === 0 || trace?.eventsCompacted === true || trace?.fullTraceLoaded !== true;
|
|
}
|
|
|
|
function traceHasEvents(trace: ChatMessage["runnerTrace"]): boolean {
|
|
return Array.isArray(trace?.events) && trace.events.length > 0;
|
|
}
|
|
|
|
function agentRunFromResult(result: AgentChatResultResponse, runnerTrace: ChatMessage["runnerTrace"]): AgentRunProvenance | null {
|
|
return asAgentRun(result.agentRun ?? runnerTrace?.agentRun);
|
|
}
|
|
|
|
function agentRunFromMessage(message: ChatMessage): AgentRunProvenance | null {
|
|
return asAgentRun((message as Record<string, unknown>).agentRun ?? message.runnerTrace?.agentRun);
|
|
}
|
|
|
|
function asAgentRun(value: unknown): AgentRunProvenance | null {
|
|
return value && typeof value === "object" ? value as AgentRunProvenance : null;
|
|
}
|
|
|
|
function normalizeAgentError(value: unknown): ChatMessage["error"] | null {
|
|
if (!value) return null;
|
|
if (typeof value === "string") return value.trim() ? { message: value.trim() } : null;
|
|
if (value && typeof value === "object") {
|
|
const record = value as Record<string, unknown>;
|
|
const message = firstNonEmptyString(record.message, record.userMessage, record.reason, record.detail, record.error);
|
|
const code = firstNonEmptyString(record.code, record.failureKind, record.name);
|
|
const category = firstNonEmptyString(record.category, record.layer, record.type);
|
|
const providerStatus = typeof record.providerStatus === "number" ? record.providerStatus : Number.isFinite(Number(record.providerStatus)) ? Number(record.providerStatus) : undefined;
|
|
return { ...record, code: code ?? undefined, message: message ?? undefined, category: category ?? undefined, providerStatus };
|
|
}
|
|
return { message: String(value) };
|
|
}
|
|
|
|
function agentErrorDisplayText(value: unknown): string | null {
|
|
const error = normalizeAgentError(value);
|
|
if (!error) return null;
|
|
return firstNonEmptyString(error.message, typeof error.userMessage === "string" ? error.userMessage : null, error.code ? `Code Agent 请求失败:${error.code}` : null);
|
|
}
|
|
|
|
function isTerminalMessageStatus(value: unknown): boolean {
|
|
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"));
|
|
}
|
|
|
|
function recordValue(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" ? value as Record<string, unknown> : null;
|
|
}
|
|
|
|
function firstArray(...values: unknown[]): TraceEvent[] {
|
|
for (const value of values) {
|
|
if (Array.isArray(value)) return value as TraceEvent[];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function firstFiniteNumber(...values: unknown[]): number | undefined {
|
|
for (const value of values) {
|
|
const number = typeof value === "number" ? value : Number(value);
|
|
if (Number.isFinite(number)) return number;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function firstBoolean(...values: unknown[]): boolean | undefined {
|
|
for (const value of values) {
|
|
if (typeof value === "boolean") return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function finalResponseText(value: unknown): string | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const record = value as Record<string, unknown>;
|
|
return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message));
|
|
}
|
|
|
|
function agentReplyText(value: AgentChatResultResponse["reply"]): string | null {
|
|
if (typeof value === "string") return value;
|
|
return value && typeof value === "object" ? firstNonEmptyString(value.content) : null;
|
|
}
|
|
|
|
function messageText(value: unknown): string | null {
|
|
if (typeof value === "string") return value.trim() || null;
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
if (Array.isArray(value)) return value.map(messageText).filter((item): item is string => Boolean(item)).join("\n") || null;
|
|
if (value && typeof value === "object") {
|
|
const record = value as Record<string, unknown>;
|
|
return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message), messageText(record.summary), messageText(record.preview), messageText(record.title));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function liveCall<T>(label: string, call: () => Promise<ApiResult<T>>): Promise<ApiResult<T>> {
|
|
try {
|
|
return await call();
|
|
} catch (error) {
|
|
return { ok: false, status: 0, data: null, error: `${label}: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
function traceHydrationSinceSeq(trace: ChatMessage["runnerTrace"]): number {
|
|
const rangeNext = Number(trace?.nextSinceSeq ?? trace?.range?.toSeq);
|
|
if (Number.isFinite(rangeNext) && rangeNext > 0 && trace?.hasMore === true) return Math.trunc(rangeNext);
|
|
const events = Array.isArray(trace?.events) ? trace.events : [];
|
|
return events.reduce((max, event) => {
|
|
const seq = Number(event.seq);
|
|
return Number.isFinite(seq) && seq > max ? Math.trunc(seq) : max;
|
|
}, 0);
|
|
}
|
|
|
|
function traceNextSinceSeq(result: AgentChatResultResponse, fallback: number): number {
|
|
const direct = Number(result.nextSinceSeq ?? result.range?.toSeq);
|
|
if (Number.isFinite(direct) && direct >= 0) return Math.trunc(direct);
|
|
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
|
|
return events.reduce((max, event) => {
|
|
const seq = Number(event.seq);
|
|
return Number.isFinite(seq) && seq > max ? Math.trunc(seq) : max;
|
|
}, fallback);
|
|
}
|
|
|
|
function makeMessage(role: ChatMessage["role"], text: string, status: ChatMessage["status"], extra: Partial<ChatMessage> = {}): ChatMessage {
|
|
return { id: nextProtocolId("msg"), role, title: extra.title ?? (role === "user" ? "用户" : "Code Agent"), text, status, createdAt: new Date().toISOString(), ...extra };
|
|
}
|
|
|
|
function statusFromResult(status: string | undefined): ChatMessage["status"] {
|
|
if (isTraceActiveStatus(status)) return "running";
|
|
if (status === "blocked") return "blocked";
|
|
if (status === "timeout") return "timeout";
|
|
if (status === "canceled" || status === "cancelled") return "canceled";
|
|
if (status === "completed") return "completed";
|
|
if (!status || status === "unknown") return "pending";
|
|
return "failed";
|
|
}
|
|
|
|
function workspaceSessionStatusFromChatStatus(status: unknown): string | null {
|
|
const normalized = normalizedStatusText(status);
|
|
if (normalized === "completed") return "idle";
|
|
if (normalized === "canceled" || normalized === "cancelled") return "canceled";
|
|
if (normalized === "blocked") return "blocked";
|
|
if (normalized === "timeout") return "timeout";
|
|
if (normalized === "failed" || normalized === "error") return "failed";
|
|
return null;
|
|
}
|
|
|
|
function isTraceActiveStatus(status: unknown): boolean {
|
|
return ["accepted", "pending", "processing", "running", "busy", "creating"].includes(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
|
|
}
|
|
|
|
function readString(key: string, fallback: string): string {
|
|
try {
|
|
return localStorage.getItem(key) ?? fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function readRecentDrafts(): DraftEntry[] {
|
|
try {
|
|
const raw = localStorage.getItem(RECENT_DRAFTS_STORAGE_KEY);
|
|
return raw ? normalizeRecentDrafts(JSON.parse(raw)) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function writeRecentDrafts(drafts: DraftEntry[]): void {
|
|
try { localStorage.setItem(RECENT_DRAFTS_STORAGE_KEY, JSON.stringify(drafts)); } catch { /* ignore */ }
|
|
}
|
|
|
|
function readNumber(key: string, fallback: number): number {
|
|
try {
|
|
const value = Number(localStorage.getItem(key));
|
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|