fix: restore workbench messages from conversation detail

This commit is contained in:
lyon
2026-06-17 07:06:14 +08:00
parent 834d882fc7
commit 70a6db185b
3 changed files with 98 additions and 58 deletions
+13 -1
View File
@@ -5,16 +5,28 @@ export interface ConversationListOptions {
includeConversationId?: string | null;
}
export interface ConversationDetailOptions {
projectId?: string | null;
}
export const workbenchAPI = {
workspace: (projectId: string): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }),
updateWorkspace: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }),
selectConversation: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }),
conversations: (projectId: string, options: ConversationListOptions = {}): Promise<ApiResult<{ conversations?: ConversationRecord[] }>> => fetchJson(conversationListPath(projectId, options), { timeoutMs: 8000, timeoutName: "conversations" }),
conversation: (conversationId: string): Promise<ApiResult<{ conversation?: ConversationRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { timeoutMs: 8000, timeoutName: "conversation" }),
conversation: (conversationId: string, options: ConversationDetailOptions = {}): Promise<ApiResult<{ conversation?: ConversationRecord }>> => fetchJson(conversationDetailPath(conversationId, options), { timeoutMs: 8000, timeoutName: "conversation" }),
deleteConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }),
saveConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" })
};
function conversationDetailPath(conversationId: string, options: ConversationDetailOptions): string {
const params = new URLSearchParams();
const projectId = typeof options.projectId === "string" ? options.projectId.trim() : "";
if (projectId) params.set("projectId", projectId);
const query = params.toString();
return `/v1/agent/conversations/${encodeURIComponent(conversationId)}${query ? `?${query}` : ""}`;
}
function conversationListPath(projectId: string, options: ConversationListOptions): string {
const params = new URLSearchParams({ projectId });
const includeConversationId = typeof options.includeConversationId === "string" ? options.includeConversationId.trim() : "";
+8 -1
View File
@@ -1,6 +1,7 @@
import type { Router } from "vue-router";
import { isNavigationFailure } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { DEFAULT_WORKBENCH_PROJECT_ID, normalizeWorkbenchProjectId } from "@/utils";
import { resolveDocumentTitle } from "./title";
const CHUNK_RELOAD_KEY = "hwlab:v03:chunk-reload-at";
@@ -29,7 +30,7 @@ export function installChunkRecovery(router: Router): void {
export function installRouterGuards(router: Router): void {
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (!auth.checked) await auth.bootstrap();
if (!auth.checked) await auth.bootstrap(projectIdFromRoute(to));
if (to.meta.requiresAuth !== false && auth.authState === "login") return { name: "Login", query: { redirect: to.fullPath } };
if (to.meta.requiresAdmin === true && !auth.isAdmin) return { name: "Dashboard", query: { blocked: "admin_required" } };
return true;
@@ -42,3 +43,9 @@ export function installRouterGuards(router: Router): void {
installChunkRecovery(router);
}
function projectIdFromRoute(to: { query?: Record<string, unknown> }): string {
const raw = to.query?.projectId;
const value = Array.isArray(raw) ? raw[0] : raw;
return normalizeWorkbenchProjectId(value) ?? DEFAULT_WORKBENCH_PROJECT_ID;
}
+77 -56
View File
@@ -17,7 +17,7 @@ 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[]>(messagesFromWorkspace(workspace.value));
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());
@@ -54,6 +54,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function hydrate(): Promise<void> {
const requestEpoch = workspaceSelectionEpoch.value;
const previousConversationId = activeConversationId.value;
conversationsReady.value = conversations.value.length > 0;
loading.value = true;
error.value = null;
@@ -64,23 +65,36 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return;
}
const nextWorkspace = workspaceResult.data?.workspace ?? null;
const conversationsResult = await api.workbench.conversations(projectId.value, { includeConversationId: selectedConversationIdFromWorkspace(nextWorkspace) });
const nextProjectId = workspaceProjectId(nextWorkspace, projectId.value);
projectId.value = nextProjectId;
const selectedConversationId = selectedConversationIdFromWorkspace(nextWorkspace);
const [conversationsResult, selectedConversationResult] = await Promise.all([
api.workbench.conversations(nextProjectId, { includeConversationId: selectedConversationId }),
selectedConversationId ? api.workbench.conversation(selectedConversationId, { projectId: nextProjectId }) : Promise.resolve(null)
]);
loading.value = false;
const selectedConversation = selectedConversationResult?.ok ? selectedConversationResult.data?.conversation ?? null : null;
let nextConversations = conversations.value;
if (conversationsResult.ok) {
nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationIdFromWorkspace(nextWorkspace), selectedConversationFromWorkspace(nextWorkspace));
nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationId, selectedConversation);
conversations.value = nextConversations;
conversationsReady.value = true;
void refreshSessionStatusAuthority(nextConversations);
} else {
if (selectedConversation) {
nextConversations = mergeConversationIntoList(nextConversations, selectedConversation);
conversations.value = nextConversations;
}
conversationsReady.value = conversations.value.length > 0;
error.value = conversations.value.length > 0 ? null : conversationsResult.error ?? "session list unavailable";
}
if (shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: nextWorkspace })) {
workspace.value = nextWorkspace;
if (selectedConversationId && selectedConversationResult && !selectedConversationResult.ok) error.value = 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;
providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value;
rememberWorkbenchProjectId(workspaceProjectId(workspace.value, projectId.value));
messages.value = messagesFromWorkspaceSelection(workspace.value, nextConversations);
rememberWorkbenchProjectId(workspaceProjectId(workspace.value, nextProjectId));
messages.value = messagesFromSelectedConversation(selectedConversation, selectedConversationId, previousConversationId, messages.value);
void hydrateTurnStatusAuthority(messages.value);
void hydrateTerminalMessageDiagnostics();
void hydrateTraceEvents(messages.value);
@@ -128,24 +142,28 @@ export const useWorkbenchStore = defineStore("workbench", () => {
conversations.value = mergeConversationIntoList(conversations.value, conversation);
conversationsReady.value = true;
workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId);
messages.value = messagesFromConversation(conversation);
void hydrateTurnStatusAuthority(messages.value);
void hydrateTraceEvents(messages.value);
void refreshSessionStatusById(sessionIdFromConversation(conversation));
loading.value = true;
const response = await api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" });
const [response, detailResponse] = await Promise.all([
api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" }),
api.workbench.conversation(conversation.conversationId, { projectId: tabProjectId })
]);
loading.value = false;
clearSwitchingConversation(conversation.conversationId);
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) return;
if (response.ok) {
workspace.value = response.data?.workspace ?? current;
messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]);
const selectedConversation = detailResponse.ok ? detailResponse.data?.conversation ?? null : null;
if (!selectedConversation) error.value = detailResponse.error ?? "conversation unavailable";
const selectedProjectId = conversationProjectId(selectedConversation ?? conversation, tabProjectId);
workspace.value = workspaceWithSelectedConversation(response.data?.workspace ?? current, selectedConversation ?? conversation, selectedProjectId);
messages.value = messagesFromSelectedConversation(selectedConversation, conversation.conversationId, null, []);
if (selectedConversation) conversations.value = mergeConversationIntoList(conversations.value, selectedConversation);
void hydrateTurnStatusAuthority(messages.value);
void hydrateTerminalMessageDiagnostics();
void hydrateTraceEvents(messages.value);
reattachRestoredActiveTrace();
currentRequest.value = null;
await refreshSelectedSessionStatus(conversation);
await refreshSelectedSessionStatus(selectedConversation ?? conversation);
await refreshConversations(conversation.conversationId);
return;
}
@@ -169,7 +187,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
await selectConversation(existing);
return activeConversationId.value === normalized;
}
const response = await api.workbench.conversation(normalized);
const response = await api.workbench.conversation(normalized, { projectId: activeProjectId.value });
const conversation = response.data?.conversation ?? null;
if (!response.ok || !conversation) {
if (isCurrentWorkspaceSelection(requestEpoch, normalized)) clearSwitchingConversation(normalized);
@@ -197,7 +215,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
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 ?? selectedConversationFromWorkspace(workspace.value));
conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId, activeConversation.value);
conversationsReady.value = true;
await refreshSessionStatusAuthority(conversations.value);
return;
@@ -628,15 +646,20 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const freshWorkspace = fresh.data?.workspace;
if (!fresh.ok || !freshWorkspace?.workspaceId) return false;
workspace.value = optimisticWorkspaceSelection(freshWorkspace, conversation, tabProjectId);
messages.value = messagesFromConversation(conversation);
const retried = await api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue-retry" });
const [retried, detailResponse] = await Promise.all([
api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue-retry" }),
api.workbench.conversation(conversation.conversationId, { projectId: tabProjectId })
]);
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) return true;
if (!retried.ok) {
error.value = retried.error ?? "session switch failed";
return false;
}
workspace.value = retried.data?.workspace ?? workspace.value;
messages.value = messagesFromWorkspaceSelection(workspace.value, [conversation, ...conversations.value]);
const selectedConversation = detailResponse.ok ? detailResponse.data?.conversation ?? null : null;
if (!selectedConversation) error.value = detailResponse.error ?? "conversation unavailable";
workspace.value = workspaceWithSelectedConversation(retried.data?.workspace ?? workspace.value, selectedConversation ?? conversation, conversationProjectId(selectedConversation ?? conversation, tabProjectId));
messages.value = messagesFromSelectedConversation(selectedConversation, conversation.conversationId, null, []);
if (selectedConversation) conversations.value = mergeConversationIntoList(conversations.value, selectedConversation);
void hydrateTurnStatusAuthority(messages.value);
void hydrateTerminalMessageDiagnostics();
void hydrateTraceEvents(messages.value);
@@ -673,27 +696,57 @@ function uniqueTraceIds(messages: ChatMessage[]): string[] {
}
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: conversation.sessionId ?? current.selectedAgentSessionId,
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: conversation.sessionId ?? current.workspace?.selectedAgentSessionId,
threadId: conversation.threadId ?? current.workspace?.threadId,
messages: conversation.messages ?? []
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 messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] {
const selected = workspace?.selectedConversation?.messages;
const embedded = workspace?.workspace?.messages;
@@ -701,38 +754,6 @@ function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[]
return source.map((message) => normalizeChatMessage(message));
}
function messagesFromWorkspaceSelection(workspace: WorkspaceRecord | null, fallbackConversations: ConversationRecord[]): ChatMessage[] {
const workspaceMessages = messagesFromWorkspace(workspace);
if (workspaceMessages.length > 0) return workspaceMessages;
const selectedConversationId = selectedConversationIdFromWorkspace(workspace);
const fallback = selectedConversationId ? fallbackConversations.find((conversation) => conversation.conversationId === selectedConversationId) : null;
return fallback ? messagesFromConversation(fallback) : [];
}
function selectedConversationFromWorkspace(workspace: WorkspaceRecord | null): ConversationRecord | null {
const selected = workspace?.selectedConversation;
if (selected?.conversationId) {
const messages = Array.isArray(selected.messages) && selected.messages.length > 0 ? selected.messages : messagesFromWorkspace(workspace);
return { ...selected, messages, messageCount: selected.messageCount ?? (messages.length || undefined) };
}
const conversationId = selectedConversationIdFromWorkspace(workspace);
if (!conversationId) return null;
const messages = messagesFromWorkspace(workspace);
const firstUserMessage = messages.find((message) => message.role === "user");
return {
conversationId,
projectId: firstNonEmptyString(workspace?.projectId, workspace?.workspace?.projectId),
sessionId: firstNonEmptyString(workspace?.selectedAgentSessionId, workspace?.workspace?.selectedAgentSessionId),
threadId: firstNonEmptyString(workspace?.workspace?.threadId),
status: firstNonEmptyString(workspace?.workspace?.sessionStatus),
updatedAt: firstNonEmptyString(workspace?.updatedAt, workspace?.workspace?.updatedAt),
lastTraceId: firstNonEmptyString(workspace?.activeTraceId, workspace?.workspace?.activeTraceId, workspace?.workspace?.lastTraceId),
firstUserMessagePreview: firstNonEmptyString(firstUserMessage?.text, firstUserMessage?.content),
messageCount: messages.length || undefined,
messages
};
}
function normalizeChatMessage(message: ChatMessage): ChatMessage {
const runnerTrace = normalizeMessageRunnerTrace(message);
const error = normalizeAgentError(message.error ?? runnerTrace?.error);