From 3a50fec856af5b04d10baf1286394ce3b10794dc Mon Sep 17 00:00:00 2001 From: lyon Date: Thu, 18 Jun 2026 12:14:57 +0800 Subject: [PATCH] fix: make workbench session route authoritative --- web/hwlab-cloud-web/index.html | 24 -- web/hwlab-cloud-web/scripts/check.ts | 8 +- .../scripts/workbench-navigation.test.ts | 16 +- web/hwlab-cloud-web/src/App.vue | 4 +- .../src/api/workbench-events.ts | 9 +- web/hwlab-cloud-web/src/api/workbench.ts | 55 ++++- .../src/components/workbench/SessionRail.vue | 8 +- .../src/composables/useWorkspaceBootstrap.ts | 56 ----- web/hwlab-cloud-web/src/router/guards.ts | 9 +- web/hwlab-cloud-web/src/router/index.ts | 2 +- .../src/router/workbench-navigation.ts | 16 +- web/hwlab-cloud-web/src/stores/auth.ts | 17 +- .../src/stores/workbench-projection.ts | 15 +- web/hwlab-cloud-web/src/stores/workbench.ts | 207 +++++++++++++----- web/hwlab-cloud-web/src/types/global.d.ts | 12 - web/hwlab-cloud-web/src/utils/index.ts | 10 +- .../src/views/workbench/CodeWorkbenchView.vue | 59 ++--- 17 files changed, 295 insertions(+), 232 deletions(-) delete mode 100644 web/hwlab-cloud-web/src/composables/useWorkspaceBootstrap.ts diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 0c58db7e..5857b09d 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -5,30 +5,6 @@ HWLAB 云工作台 -
diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 67eeaeb1..a45930fe 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -24,7 +24,6 @@ const requiredFiles = Object.freeze([ "src/stores/auth.ts", "src/stores/workbench.ts", "src/composables/useTraceSubscription.ts", - "src/composables/useWorkspaceBootstrap.ts", "src/composables/useAutoRefresh.ts", "src/composables/useClipboard.ts", "src/composables/useForm.ts", @@ -65,8 +64,9 @@ const messageTraceDebugSource = readWeb("src/components/workbench/MessageTraceDe assert.match(html, /
<\/div>/u, "index.html must expose Vue mount root"); assert.match(html, /src="\/src\/main\.ts"/u, "index.html must load Vue TS entry"); -assert.match(html, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "workspace bootstrap must still start during HTML parse"); -assert.match(html, /\/v1\/workbench\/workspace/u, "HTML parse bootstrap must use the same-origin workbench API"); +assert.doesNotMatch(html, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "workspace bootstrap must not start during HTML parse"); +assert.doesNotMatch(html, /HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP/u, "workspace snapshot must not be injected before Vue boot"); +assert.doesNotMatch(html, /\/v1\/workbench\/workspace/u, "HTML parse must not request legacy workspace authority"); assert.doesNotMatch(html, /\/auth\/workspace-bootstrap/u, "HTML parse bootstrap must not use stale auth workspace bootstrap route"); assert.doesNotMatch(html, /src="\/src\/main\.tsx"|id="root"/u, "React entry must be gone"); @@ -103,7 +103,7 @@ assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_ assertIncludes(appSource, "/v1/workbench/events", "Workbench realtime client must use the RESTful same-origin events endpoint"); assertIncludes(appSource, "/v1/agent/traces/", "trace hydration must use RESTful Cloud Web same-origin trace API"); assert.doesNotMatch(appSource, /\/v1\/agent\/chat\/trace\//u, "Cloud Web must not use the legacy action-style chat trace API"); -assertIncludes(appSource, "/v1/workbench/workspace", "workspace bootstrap must use Cloud Web same-origin workbench API"); +assert.doesNotMatch(appSource, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "early workspace bootstrap helper must not remain in Cloud Web app source"); assert.doesNotMatch(appSource, /\/auth\/workspace-bootstrap/u, "stale auth workspace bootstrap route must not remain in Cloud Web app source"); assert.doesNotMatch(appSource, /trace-meta-details/u, "Trace details must not render a second message-detail exclamation button"); assert.doesNotMatch(traceTimelineSource, /trace-meta-panel|trace-summary-chip|trace-meta-actions|原始事件|traceEventLabel/u, "Expanded TraceTimeline body must not render debug identity or raw-event controls"); diff --git a/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts b/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts index c6f7fc5e..fe5ca338 100644 --- a/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts @@ -1,22 +1,22 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isWorkbenchNavigationContext, shouldReflectWorkbenchConversationUrl } from "../src/router/workbench-navigation.ts"; +import { isWorkbenchNavigationContext, shouldReflectWorkbenchSessionUrl } from "../src/router/workbench-navigation.ts"; test("workbench navigation context recognizes workbench routes only while the component is active", () => { - assert.equal(isWorkbenchNavigationContext({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/cnv_1" }), true); + assert.equal(isWorkbenchNavigationContext({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/ses_1" }), true); assert.equal(isWorkbenchNavigationContext({ componentActive: true, routeName: "CodeWorkbench", routePath: "/workspace" }), true); assert.equal(isWorkbenchNavigationContext({ componentActive: true, routeSection: "user", routeName: "Dashboard", routePath: "/dashboard" }), false); - assert.equal(isWorkbenchNavigationContext({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/cnv_1" }), false); + assert.equal(isWorkbenchNavigationContext({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/ses_1" }), false); }); test("workbench URL reflection never overrides a user navigation outside Workbench", () => { - assert.equal(shouldReflectWorkbenchConversationUrl({ componentActive: true, routeSection: "user", routeName: "Dashboard", routePath: "/dashboard", routeConversationId: null, conversationId: "cnv_async" }), false); - assert.equal(shouldReflectWorkbenchConversationUrl({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbench", routePath: "/workbench", routeConversationId: null, conversationId: "cnv_async" }), false); + assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: true, routeSection: "user", routeName: "Dashboard", routePath: "/dashboard", routeSessionId: null, sessionId: "ses_async" }), false); + assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbench", routePath: "/workbench", routeSessionId: null, sessionId: "ses_async" }), false); }); test("workbench URL reflection still updates empty Workbench routes and respects route hydration", () => { - assert.equal(shouldReflectWorkbenchConversationUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbench", routePath: "/workbench", routeConversationId: null, conversationId: "cnv_selected" }), true); - assert.equal(shouldReflectWorkbenchConversationUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/cnv_selected", routeConversationId: "cnv_selected", conversationId: "cnv_selected" }), false); - assert.equal(shouldReflectWorkbenchConversationUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/cnv_route", routeConversationId: "cnv_route", conversationId: "cnv_store", applyingRouteConversation: true }), false); + assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbench", routePath: "/workbench", routeSessionId: null, sessionId: "ses_selected" }), true); + assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/ses_selected", routeSessionId: "ses_selected", sessionId: "ses_selected" }), false); + assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: true, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/ses_route", routeSessionId: "ses_route", sessionId: "ses_store", applyingRouteSession: true }), false); }); diff --git a/web/hwlab-cloud-web/src/App.vue b/web/hwlab-cloud-web/src/App.vue index ca875afd..435e79bd 100644 --- a/web/hwlab-cloud-web/src/App.vue +++ b/web/hwlab-cloud-web/src/App.vue @@ -4,13 +4,11 @@ import { RouterView } from "vue-router"; import ToastHost from "@/components/common/ToastHost.vue"; import AppShell from "@/components/layout/AppShell.vue"; import { useAuthStore } from "@/stores/auth"; -import { useWorkbenchStore } from "@/stores/workbench"; const auth = useAuthStore(); -const workbench = useWorkbenchStore(); onMounted(async () => { - if (!auth.checked) await auth.bootstrap(workbench.projectId); + if (!auth.checked) await auth.bootstrap(); }); diff --git a/web/hwlab-cloud-web/src/api/workbench-events.ts b/web/hwlab-cloud-web/src/api/workbench-events.ts index e3f7448d..febf9698 100644 --- a/web/hwlab-cloud-web/src/api/workbench-events.ts +++ b/web/hwlab-cloud-web/src/api/workbench-events.ts @@ -1,4 +1,4 @@ -// 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. +// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // Responsibility: Workbench SSE client. Realtime events accelerate UI projection; REST snapshots remain gap-fill authority. import type { TraceEvent, WorkspaceRecord } from "@/types"; @@ -18,7 +18,7 @@ export interface WorkbenchRealtimeEvent { status?: string; serverSentAt?: string | null; eventCreatedAt?: string | null; - projectId?: string; + projectId?: string | null; workspace?: WorkspaceRecord | null; workspaceId?: string | null; workspaceRevision?: number | null; @@ -34,7 +34,7 @@ export interface WorkbenchRealtimeEvent { } export interface WorkbenchEventStreamOptions { - projectId: string; + projectId?: string | null; workspaceId?: string | null; conversationId?: string | null; sessionId?: string | null; @@ -61,7 +61,8 @@ const WORKBENCH_EVENT_NAMES = [ export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): WorkbenchEventStream | null { if (typeof EventSource === "undefined") return null; - const params = new URLSearchParams({ projectId: options.projectId }); + const params = new URLSearchParams(); + appendParam(params, "projectId", options.projectId); appendParam(params, "workspaceId", options.workspaceId); appendParam(params, "conversationId", options.conversationId); appendParam(params, "sessionId", options.sessionId); diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index 27ef80e8..e45e3eb3 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -1,5 +1,30 @@ import { fetchJson } from "./client"; -import type { ApiResult, ConversationRecord, WorkspaceRecord } from "@/types"; +import type { ApiResult, ChatMessage, ConversationRecord, WorkspaceRecord } from "@/types"; + +export interface WorkbenchSessionListResponse { + projectId?: string | null; + sessions?: ConversationRecord[]; + conversations?: ConversationRecord[]; + count?: number; +} + +export interface WorkbenchSessionDetailResponse { + session?: ConversationRecord; +} + +export interface WorkbenchMessagePageResponse { + sessionId?: string | null; + conversationId?: string | null; + messages?: ChatMessage[]; + count?: number; + total?: number; + hasMore?: boolean; +} + +export interface SessionListOptions { + includeSessionId?: string | null; + timeoutMs?: number | null; +} export interface ConversationListOptions { includeConversationId?: string | null; @@ -11,10 +36,21 @@ export interface ConversationDetailOptions { timeoutMs?: number | null; } +export interface SessionMessageOptions { + limit?: number | null; + cursor?: string | null; + timeoutMs?: number | null; +} + +const SESSION_LIST_TIMEOUT_MS = 30_000; +const SESSION_DETAIL_TIMEOUT_MS = 45_000; const CONVERSATION_LIST_TIMEOUT_MS = 30_000; const CONVERSATION_DETAIL_TIMEOUT_MS = 45_000; export const workbenchAPI = { + sessions: (options: SessionListOptions = {}): Promise> => fetchJson(sessionListPath(options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_LIST_TIMEOUT_MS), timeoutName: "workbench sessions" }), + session: (sessionId: string, timeoutMs: number | null = null): Promise> => fetchJson(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: requestTimeoutMs(timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" }), + sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }), workspace: (projectId: string): Promise> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }), updateWorkspace: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }), selectConversation: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }), @@ -24,6 +60,23 @@ export const workbenchAPI = { saveConversation: (conversationId: string, payload: Record): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" }) }; +function sessionListPath(options: SessionListOptions): string { + const params = new URLSearchParams(); + const includeSessionId = typeof options.includeSessionId === "string" ? options.includeSessionId.trim() : ""; + if (includeSessionId) params.set("includeSessionId", includeSessionId); + const query = params.toString(); + return `/v1/workbench/sessions${query ? `?${query}` : ""}`; +} + +function sessionMessagesPath(sessionId: string, options: SessionMessageOptions): string { + const params = new URLSearchParams(); + if (Number.isFinite(options.limit)) params.set("limit", String(Math.max(1, Math.trunc(Number(options.limit))))); + const cursor = typeof options.cursor === "string" ? options.cursor.trim() : ""; + if (cursor) params.set("cursor", cursor); + const query = params.toString(); + return `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages${query ? `?${query}` : ""}`; +} + function conversationDetailPath(conversationId: string, options: ConversationDetailOptions): string { const params = new URLSearchParams(); const projectId = typeof options.projectId === "string" ? options.projectId.trim() : ""; diff --git a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue index 72ef61c1..b3dcfa4a 100644 --- a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue +++ b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue @@ -3,7 +3,7 @@ import { computed, ref } from "vue"; import { useWorkbenchStore } from "@/stores/workbench"; import { useClipboard } from "@/composables/useClipboard"; import LoadingState from "@/components/common/LoadingState.vue"; -import { normalizeWorkbenchProjectId, workbenchSessionPath } from "@/utils"; +import { workbenchSessionPath } from "@/utils"; const workbench = useWorkbenchStore(); const { copied, copy } = useClipboard(); @@ -18,10 +18,8 @@ function copyActive(): void { void copy(`url=${sessionUrl(tab)}\nconversation=${tab.conversationId}\nsession=${tab.sessionId ?? ''}\nthread=${tab.threadId ?? ''}\ntrace=${tab.lastTraceId ?? ''}`); } -function sessionUrl(tab: { conversationId: string }): string { - const url = new URL(workbenchSessionPath(tab.conversationId), window.location.origin); - const projectId = normalizeWorkbenchProjectId(workbench.workspace?.projectId) ?? normalizeWorkbenchProjectId(workbench.workspace?.workspace?.projectId); - if (projectId) url.searchParams.set("projectId", projectId); +function sessionUrl(tab: { sessionId?: string | null; conversationId: string }): string { + const url = new URL(workbenchSessionPath(tab.sessionId || tab.conversationId), window.location.origin); return url.toString(); } diff --git a/web/hwlab-cloud-web/src/composables/useWorkspaceBootstrap.ts b/web/hwlab-cloud-web/src/composables/useWorkspaceBootstrap.ts deleted file mode 100644 index 0b9a1ab0..00000000 --- a/web/hwlab-cloud-web/src/composables/useWorkspaceBootstrap.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { authAPI, workbenchAPI, type WorkspaceBootstrapResponse } from "@/api"; -import type { ApiResult, WorkspaceRecord } from "@/types"; -import { DEFAULT_WORKBENCH_PROJECT_ID, asRecord, normalizeWorkbenchProjectId } from "@/utils"; - -export async function consumeEarlyWorkspaceBootstrap(projectId: string): Promise | null> { - const pending = window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP; - if (!pending) return null; - window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP = undefined; - const early = await pending; - const earlyProjectId = normalizeWorkbenchProjectId(early.projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID; - if (earlyProjectId !== projectId) return null; - return { - ok: early.ok === true, - status: Number.isFinite(Number(early.status)) ? Number(early.status) : 0, - data: asRecord(early.data) as WorkspaceBootstrapResponse | null, - error: early.ok === true ? null : early.error ?? `HTTP ${early.status ?? 0}` - }; -} - -export async function resolveWorkspaceBootstrap(projectId: string): Promise> { - const normalizedProjectId = normalizeWorkbenchProjectId(projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID; - const earlyWorkspace = await consumeEarlyWorkspaceBootstrap(normalizedProjectId); - return resolveWorkspaceBootstrapFromCurrentApi(normalizedProjectId, earlyWorkspace); -} - -async function resolveWorkspaceBootstrapFromCurrentApi(projectId: string, earlyWorkspace: ApiResult | null): Promise> { - const session = await authAPI.session(); - const sessionPayload = session.data ?? { authenticated: false, mode: "server" as const }; - if (!session.ok || sessionPayload.authenticated !== true) { - return { - ok: session.ok, - status: session.status, - data: { ...sessionPayload, projectId, workspace: null, workspaceStatus: earlyWorkspace?.status ?? null, workspaceError: session.error ?? session.status }, - error: session.error - }; - } - const workspace = earlyWorkspace?.ok ? earlyWorkspace : await workbenchAPI.workspace(projectId); - const workspacePayload = workspace.ok ? asRecord(workspace.data) : null; - return { - ok: true, - status: session.status, - data: { - ...sessionPayload, - projectId, - workspace: (workspacePayload?.workspace as WorkspaceRecord | null | undefined) ?? null, - workspaceStatus: workspace.status, - workspaceError: workspace.ok ? null : workspace.error ?? workspace.status - }, - error: null - }; -} - -export function publishWorkspaceBootstrap(projectId: string, workspace: WorkspaceRecord | null | undefined): void { - if (!workspace) return; - window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP = { projectId, workspace }; -} diff --git a/web/hwlab-cloud-web/src/router/guards.ts b/web/hwlab-cloud-web/src/router/guards.ts index 027f88b4..18e3999f 100644 --- a/web/hwlab-cloud-web/src/router/guards.ts +++ b/web/hwlab-cloud-web/src/router/guards.ts @@ -1,7 +1,6 @@ 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"; @@ -30,7 +29,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(projectIdFromRoute(to)); + if (!auth.checked) await auth.bootstrap(); 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; @@ -43,9 +42,3 @@ export function installRouterGuards(router: Router): void { installChunkRecovery(router); } - -function projectIdFromRoute(to: { query?: Record }): string { - const raw = to.query?.projectId; - const value = Array.isArray(raw) ? raw[0] : raw; - return normalizeWorkbenchProjectId(value) ?? DEFAULT_WORKBENCH_PROJECT_ID; -} diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts index 1c8280a6..6a002f24 100644 --- a/web/hwlab-cloud-web/src/router/index.ts +++ b/web/hwlab-cloud-web/src/router/index.ts @@ -8,7 +8,7 @@ const routes: RouteRecordRaw[] = [ { path: "/login", name: "Login", component: () => import("@/views/LoginView.vue"), meta: { requiresAuth: false, title: "登录" } }, { path: "/register", name: "Register", component: () => import("@/views/RegisterView.vue"), meta: { requiresAuth: false, title: "注册" } }, { path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, title: "平台概览", section: "user" } }, - { path: "/workbench/sessions/:conversationId", alias: ["/workspace/sessions/:conversationId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } }, + { path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } }, { path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } }, { path: "/api-keys", name: "ApiKeys", component: () => import("@/views/user/ApiKeysView.vue"), meta: { requiresAuth: true, title: "API Keys", section: "user" } }, { path: "/usage", name: "Usage", component: () => import("@/views/user/UsageView.vue"), meta: { requiresAuth: true, title: "用量与性能", section: "user" } }, diff --git a/web/hwlab-cloud-web/src/router/workbench-navigation.ts b/web/hwlab-cloud-web/src/router/workbench-navigation.ts index c6812fff..17ec66af 100644 --- a/web/hwlab-cloud-web/src/router/workbench-navigation.ts +++ b/web/hwlab-cloud-web/src/router/workbench-navigation.ts @@ -3,9 +3,9 @@ export interface WorkbenchNavigationContext { routeName?: unknown; routePath?: unknown; routeSection?: unknown; - routeConversationId?: string | null; - conversationId?: string | null; - applyingRouteConversation?: boolean; + routeSessionId?: string | null; + sessionId?: string | null; + applyingRouteSession?: boolean; } const WORKBENCH_ROUTE_NAMES = new Set(["CodeWorkbench", "CodeWorkbenchSession"]); @@ -19,9 +19,13 @@ export function isWorkbenchNavigationContext(input: WorkbenchNavigationContext): } export function shouldReflectWorkbenchConversationUrl(input: WorkbenchNavigationContext): boolean { - if (!input.conversationId) return false; + return shouldReflectWorkbenchSessionUrl(input); +} + +export function shouldReflectWorkbenchSessionUrl(input: WorkbenchNavigationContext): boolean { + if (!input.sessionId) return false; if (!isWorkbenchNavigationContext(input)) return false; - if (input.routeConversationId === input.conversationId) return false; - if (input.applyingRouteConversation && input.routeConversationId && input.routeConversationId !== input.conversationId) return false; + if (input.routeSessionId === input.sessionId) return false; + if (input.applyingRouteSession && input.routeSessionId && input.routeSessionId !== input.sessionId) return false; return true; } diff --git a/web/hwlab-cloud-web/src/stores/auth.ts b/web/hwlab-cloud-web/src/stores/auth.ts index b2a11514..22c2b1c1 100644 --- a/web/hwlab-cloud-web/src/stores/auth.ts +++ b/web/hwlab-cloud-web/src/stores/auth.ts @@ -1,9 +1,8 @@ import { computed, ref } from "vue"; import { defineStore } from "pinia"; import { authAPI, HWLAB_WEB_SESSION_COOKIE } from "@/api"; -import { publishWorkspaceBootstrap, resolveWorkspaceBootstrap } from "@/composables/useWorkspaceBootstrap"; import type { AuthSession, AuthState, AuthUser } from "@/types"; -import { DEFAULT_WORKBENCH_PROJECT_ID, asRecord, nonEmptyString, normalizeWorkbenchProjectId } from "@/utils"; +import { asRecord, nonEmptyString } from "@/utils"; export const useAuthStore = defineStore("auth", () => { const authState = ref("checking"); @@ -15,17 +14,13 @@ export const useAuthStore = defineStore("auth", () => { const user = computed(() => session.value?.user ?? session.value?.actor ?? null); const isAdmin = computed(() => user.value?.role === "admin"); - async function bootstrap(projectId = DEFAULT_WORKBENCH_PROJECT_ID): Promise { + async function bootstrap(): Promise { if (authState.value !== "checking") checked.value = true; - const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth; - const mode = config?.mode ?? "auto"; - const normalizedProjectId = normalizeWorkbenchProjectId(projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID; - const current = mode === "auto" ? await resolveWorkspaceBootstrap(normalizedProjectId) : await authAPI.session(); + const current = await authAPI.session(); if (current.ok && current.data?.authenticated === true) { session.value = sessionFromPayload(current.data); authState.value = "authenticated"; checked.value = true; - publishWorkspaceBootstrap(normalizedProjectId, workspaceFromUnknown(asRecord(current.data)?.workspace)); document.body.dataset.authState = "authenticated"; return; } @@ -88,12 +83,6 @@ function userFromUnknown(value: unknown): AuthUser | undefined { return { id: nonEmptyString(record.id) ?? undefined, username: nonEmptyString(record.username) ?? undefined, name: nonEmptyString(record.name) ?? undefined, displayName: nonEmptyString(record.displayName) ?? undefined, role: nonEmptyString(record.role) ?? undefined, status: nonEmptyString(record.status) ?? undefined }; } -function workspaceFromUnknown(value: unknown): { workspaceId: string } | null { - const record = asRecord(value); - const workspaceId = nonEmptyString(record?.workspaceId); - return workspaceId ? { ...record, workspaceId } : null; -} - function loginFailureMessage(response: Awaited>): string { const code = loginErrorCode(response.data) ?? nonEmptyString(response.error); const normalizedCode = code?.trim().toLowerCase().replace(/_/gu, "-") ?? ""; diff --git a/web/hwlab-cloud-web/src/stores/workbench-projection.ts b/web/hwlab-cloud-web/src/stores/workbench-projection.ts index 31fb404e..c82651d3 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-projection.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-projection.ts @@ -1,7 +1,7 @@ -// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0. +// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1. // Responsibility: Workbench UI projection helpers that keep route/explicit selection separate from server snapshots. -import { firstNonEmptyString, normalizeWorkbenchConversationId } from "@/utils"; +import { firstNonEmptyString, normalizeWorkbenchConversationId, normalizeWorkbenchSessionId } from "@/utils"; export interface WorkbenchActiveConversationInput { switchingConversationId?: string | null; @@ -23,6 +23,17 @@ export function initialWorkbenchConversationIdFromLocation(location: Pick | null = typeof window === "undefined" ? null : window.location): string | null { + const pathname = location?.pathname ?? ""; + const match = /^\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u.exec(pathname); + if (!match?.[1]) return null; + try { + return normalizeWorkbenchSessionId(decodeURIComponent(match[1])); + } catch { + return normalizeWorkbenchSessionId(match[1]); + } +} + export function nextExplicitWorkbenchConversationId(current: string | null, candidate: string | null | undefined): string | null { return normalizeWorkbenchConversationId(candidate) ?? current; } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 74602e05..cb17b7b9 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -7,10 +7,10 @@ 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 { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, normalizeWorkbenchSessionId, 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 { initialWorkbenchConversationIdFromLocation, initialWorkbenchSessionIdFromLocation, 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; @@ -21,6 +21,7 @@ const TRACE_HYDRATION_MAX_ATTEMPTS = 3; const TRACE_HYDRATION_RETRY_DELAY_MS = 700; interface HydrateOptions { + sessionId?: string | null; conversationId?: string | null; } @@ -31,7 +32,7 @@ interface SessionCreatePersistenceResult { export const useWorkbenchStore = defineStore("workbench", () => { const projectId = ref(resolveInitialWorkbenchProjectId()); - const workspace = ref(window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?.workspace ?? null); + const workspace = ref(null); const conversations = ref([]); const messages = ref([]); const providerProfile = ref(readString("hwlab.workbench.providerProfile.v1", "codex")); @@ -49,6 +50,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { 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(initialWorkbenchConversationIdFromLocation()); + const explicitSessionId = ref(initialWorkbenchSessionIdFromLocation()); const workspaceSelectionEpoch = ref(0); const serverState = ref(createWorkbenchServerState()); const sessionStatusAuthority = computed(() => selectSessionStatusAuthority(serverState.value)); @@ -65,6 +67,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { 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 activeSessionId = computed(() => firstNonEmptyString(selectedSessionId.value, explicitSessionId.value)); 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 })); @@ -78,7 +81,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function hydrate(options: HydrateOptions = {}): Promise { + const routeSessionId = normalizeWorkbenchSessionId(options.sessionId); const routeConversationId = normalizeWorkbenchConversationId(options.conversationId); + if (routeSessionId) setActiveSessionSelection(routeSessionId); if (routeConversationId) setActiveConversationSelection(routeConversationId); const requestEpoch = workspaceSelectionEpoch.value; const previousConversationId = activeConversationId.value; @@ -86,58 +91,51 @@ export const useWorkbenchStore = defineStore("workbench", () => { conversationsReady.value = conversations.value.length > 0; loading.value = true; error.value = null; - const workspaceResult = await api.workbench.workspace(projectId.value); - if (!workspaceResult.ok) { + const includeSessionId = routeSessionId ?? activeSessionId.value; + const sessionsResult = await api.workbench.sessions({ includeSessionId }); + if (!sessionsResult.ok) { clearConversationDetailLoading(routeConversationId); loading.value = false; - error.value = workspaceResult.error ?? "workspace unavailable"; + error.value = sessionsResult.error ?? "session list 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); + const sessionConversations = workbenchConversationsFromSessionPayload(sessionsResult.data); + const selectedConversationId = routeConversationId + ?? conversationIdForSessionId(sessionConversations, includeSessionId) + ?? activeConversationId.value + ?? selectedConversationIdFromWorkspace(workspace.value) + ?? sessionConversations[0]?.conversationId + ?? null; 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; + const selectedSessionIdForDetail = includeSessionId ?? sessionIdFromConversation(sessionConversations.find((item) => item.conversationId === selectedConversationId)); + const selectedConversation = selectedSessionIdForDetail + ? await loadWorkbenchSessionConversation(selectedSessionIdForDetail, sessionConversations.find((item) => item.sessionId === selectedSessionIdForDetail) ?? null) + : selectedConversationId + ? await loadLegacyConversationDetail(selectedConversationId, activeProjectId.value) + : 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); + if (selectedConversationId && !selectedConversation) error.value = "session URL not found"; + const selectedProjectId = conversationProjectId(selectedConversation, activeProjectId.value); + if (selectedConversation && isCurrentWorkspaceSelection(requestEpoch, selectedConversation.conversationId)) { + workspace.value = workspaceWithSelectedConversation(workspace.value, selectedConversation, selectedProjectId); + if (workspace.value) rememberWorkspaceSnapshot(selectedProjectId, workspace.value); + setActiveSessionSelection(sessionIdFromConversation(selectedConversation)); providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value; - rememberWorkbenchProjectId(workspaceProjectId(workspace.value, nextProjectId)); + rememberWorkbenchProjectId(workspaceProjectId(workspace.value, selectedProjectId)); 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"; - } + const nextConversations = stableConversationList(conversations.value, sessionConversations, selectedConversationId, selectedConversation); + conversations.value = nextConversations; + rememberConversationList(nextConversations); + conversationsReady.value = true; + void refreshSessionStatusAuthority(nextConversations); loading.value = false; await refreshProviderOptions(); restartRealtime("hydrate"); @@ -162,6 +160,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const selectedProjectId = activeProjectId.value; const optimisticConversation = makeOptimisticConversation(selectedProjectId); setActiveConversationSelection(optimisticConversation.conversationId); + setActiveSessionSelection(optimisticConversation.sessionId); conversations.value = mergeConversationIntoList(conversations.value, optimisticConversation); rememberConversationDetail(optimisticConversation); conversationsReady.value = true; @@ -189,6 +188,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const tabProjectId = conversation.projectId ?? activeProjectId.value; switchingConversationId.value = conversation.conversationId; setActiveConversationSelection(conversation.conversationId); + setActiveSessionSelection(sessionIdFromConversation(conversation)); conversationDetailLoadingId.value = conversation.conversationId; conversations.value = mergeConversationIntoList(conversations.value, conversation); rememberConversationDetail(conversation); @@ -266,6 +266,36 @@ export const useWorkbenchStore = defineStore("workbench", () => { return activeConversationId.value === normalized; } + async function selectSessionById(sessionId: string): Promise { + const normalized = normalizeWorkbenchSessionId(sessionId); + if (!normalized) return selectConversationById(sessionId); + const previousConversationId = activeConversationId.value; + const previousMessages = messages.value; + setActiveSessionSelection(normalized); + const existing = visibleConversations.value.find((conversation) => sessionIdFromConversation(conversation) === normalized) ?? null; + if (existing && (existing.messages?.length ?? 0) > 0) { + applySelectedConversationDetail(existing, workspace.value, conversationProjectId(existing, activeProjectId.value), previousConversationId, previousMessages); + return sessionIdFromConversation(activeConversation.value) === normalized; + } + const requestEpoch = beginWorkspaceSelection(); + startWorkbenchSessionSwitch({ conversationId: existing?.conversationId ?? normalized, source: "deeplink", targetState: existing?.status ?? "unknown", cache: "cold" }); + const conversation = await loadWorkbenchSessionConversation(normalized, existing); + if (!conversation || isArchivedConversation(conversation)) { + clearConversationDetailLoading(existing?.conversationId ?? null); + isolateConversationLoadFailure(existing?.conversationId ?? previousConversationId ?? "", previousConversationId, previousMessages, isArchivedConversation(conversation) ? "session archived" : "session URL not found"); + failWorkbenchSessionSwitch(existing?.conversationId ?? normalized); + return false; + } + if (!isCurrentWorkspaceSelection(requestEpoch)) return true; + applySelectedConversationDetail(conversation, workspace.value, conversationProjectId(conversation, activeProjectId.value), previousConversationId, previousMessages); + clearSwitchingConversation(conversation.conversationId); + clearConversationDetailLoading(conversation.conversationId); + await refreshSelectedSessionStatus(conversation); + await refreshConversations(conversation.conversationId); + finishWorkbenchSessionSwitchFullLoad(conversation.conversationId, "ok"); + return sessionIdFromConversation(activeConversation.value) === normalized; + } + async function deleteCurrentSession(): Promise { const conversationId = activeConversationId.value; if (!conversationId) return; @@ -285,9 +315,10 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function refreshConversations(includeConversationId: string | null = currentListIncludeConversationId()): Promise { - const response = await api.workbench.conversations(activeProjectId.value, { includeConversationId }); + const includeSessionId = sessionIdFromConversation(visibleConversations.value.find((item) => item.conversationId === includeConversationId)) ?? activeSessionId.value; + const response = await api.workbench.sessions({ includeSessionId }); if (response.ok) { - conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId, activeConversation.value); + conversations.value = stableConversationList(conversations.value, workbenchConversationsFromSessionPayload(response.data), includeConversationId, activeConversation.value); rememberConversationList(conversations.value); conversationsReady.value = true; await refreshSessionStatusAuthority(conversations.value); @@ -418,6 +449,10 @@ export const useWorkbenchStore = defineStore("workbench", () => { explicitConversationId.value = nextExplicitWorkbenchConversationId(explicitConversationId.value, conversationId); } + function setActiveSessionSelection(sessionId: string | null | undefined): void { + explicitSessionId.value = normalizeWorkbenchSessionId(sessionId) ?? explicitSessionId.value; + } + function replaceActiveConversationSelection(conversationId: string | null | undefined): void { explicitConversationId.value = normalizeWorkbenchConversationId(conversationId); } @@ -784,17 +819,14 @@ export const useWorkbenchStore = defineStore("workbench", () => { 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("|"); + const key = [sessionId ?? "", traceId ?? "", workspaceId ?? ""].join("|"); if (key === realtimeKey && realtimeStream) return; stopRealtime(); realtimeKey = key; - if (!workspaceId && !traceId) return; + if (!sessionId && !workspaceId && !traceId) return; realtimeStream = connectWorkbenchEvents({ - projectId: activeProjectId.value, workspaceId, - conversationId, sessionId, traceId, onOpen: () => scheduleRealtimeGapHydration(`${reason}:open`), @@ -915,12 +947,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function hydrateRealtimeGap(reason: string): Promise { recordActivity(`realtime-gap:${reason}`); - const [workspaceResult] = await Promise.all([ - api.workbench.workspace(activeProjectId.value), + await Promise.all([ + refreshConversations(), hydrateTurnStatusAuthority(messages.value), hydrateTraceEvents(messages.value) ]); - if (workspaceResult.ok && workspaceResult.data?.workspace) applyRealtimeWorkspaceSnapshot(workspaceResult.data.workspace); restartRealtime(`gap:${reason}`); } @@ -1034,7 +1065,13 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function ensureWorkspace(): Promise { if (workspace.value) return workspace.value; - await hydrate(); + const response = await api.workbench.workspace(activeProjectId.value); + if (response.ok && response.data?.workspace) { + workspace.value = response.data.workspace; + rememberWorkspaceSnapshot(activeProjectId.value, workspace.value); + return workspace.value; + } + await hydrate({ sessionId: activeSessionId.value }); return workspace.value; } @@ -1204,9 +1241,72 @@ export const useWorkbenchStore = defineStore("workbench", () => { 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 }; + return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, conversationDetailLoading, chatPending, error, activeConversationId, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, selectSessionById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity }; }); +function workbenchConversationsFromSessionPayload(payload: unknown): ConversationRecord[] { + const record = recordValue(payload); + const conversations = Array.isArray(record?.conversations) ? record.conversations as ConversationRecord[] : []; + if (conversations.length > 0) return conversations.filter((item) => Boolean(normalizeWorkbenchConversationId(item.conversationId))); + const sessions = Array.isArray(record?.sessions) ? record.sessions : []; + return sessions.map((item) => conversationFromWorkbenchSession(item)).filter((item): item is ConversationRecord => Boolean(item)); +} + +function conversationFromWorkbenchSession(value: unknown): ConversationRecord | null { + const record = recordValue(value); + const sessionId = normalizeWorkbenchSessionId(record?.sessionId); + const conversationId = normalizeWorkbenchConversationId(record?.conversationId); + if (!sessionId || !conversationId) return null; + return { + conversationId, + sessionId, + projectId: firstNonEmptyString(record?.projectId), + threadId: firstNonEmptyString(record?.threadId), + status: firstNonEmptyString(record?.status), + updatedAt: firstNonEmptyString(record?.updatedAt), + startedAt: firstNonEmptyString(record?.startedAt), + lastTraceId: firstNonEmptyString(record?.lastTraceId), + messageCount: firstFiniteNumber(record?.messageCount) ?? undefined, + firstUserMessagePreview: firstNonEmptyString(record?.firstUserMessagePreview), + session: { sessionId, threadId: firstNonEmptyString(record?.threadId), status: firstNonEmptyString(record?.status) } + }; +} + +function conversationIdForSessionId(conversations: ConversationRecord[], sessionId: string | null | undefined): string | null { + const id = normalizeWorkbenchSessionId(sessionId); + if (!id) return null; + return conversations.find((conversation) => sessionIdFromConversation(conversation) === id)?.conversationId ?? null; +} + +async function loadWorkbenchSessionConversation(sessionId: string, seed: ConversationRecord | null = null): Promise { + const id = normalizeWorkbenchSessionId(sessionId); + if (!id) return null; + const [detail, messages] = await Promise.all([ + api.workbench.session(id), + api.workbench.sessionMessages(id, { limit: 100 }) + ]); + const detailConversation = detail.ok ? conversationFromWorkbenchSession(detail.data?.session) : null; + const base = detailConversation ?? seed; + if (!base) return null; + const page = messages.ok ? messages.data : null; + const pageMessages = Array.isArray(page?.messages) ? page.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : base.messages; + return { + ...base, + sessionId: id, + conversationId: normalizeWorkbenchConversationId(base.conversationId) ?? normalizeWorkbenchConversationId(page?.conversationId) ?? base.conversationId, + messages: pageMessages, + messageCount: page?.total ?? pageMessages?.length ?? base.messageCount + }; +} + +async function loadLegacyConversationDetail(conversationId: string, projectId: string): Promise { + const id = normalizeWorkbenchConversationId(conversationId); + if (!id) return null; + const response = await api.workbench.conversation(id, { projectId }); + const conversation = response.ok && !isArchivedConversation(response.data?.conversation) ? response.data?.conversation ?? null : null; + return conversation; +} + function sessionIdFromConversation(conversation: ConversationRecord | null | undefined): string | null { return firstNonEmptyString(conversation?.sessionId, conversation?.session?.sessionId) ?? null; } @@ -1304,6 +1404,7 @@ function messagesFromSelectedConversation(conversation: ConversationRecord | nul } function normalizeChatMessage(message: ChatMessage): ChatMessage { + const role = (message.role as string) === "assistant" ? "agent" : message.role; const runnerTrace = normalizeMessageRunnerTrace(message); const error = normalizeAgentError(message.error ?? runnerTrace?.error); const agentRun = agentRunFromMessage(message) ?? asAgentRun(runnerTrace?.agentRun); @@ -1312,11 +1413,11 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage { const finalText = firstNonEmptyString(finalResponseText((message as Record).finalResponse), finalResponseText(runnerTrace?.finalResponse)); const traceAssistantText = assistantTextFromTraceEvents(Array.isArray(runnerTrace?.events) ? runnerTrace.events : []); const errorText = agentErrorDisplayText(error); - const text = message.role === "agent" && isTerminalMessageStatus(status) + const text = role === "agent" && isTerminalMessageStatus(status) ? firstNonEmptyString(finalText, errorText, traceAssistantText, baseText) ?? "" : firstNonEmptyString(baseText, finalText, traceAssistantText, errorText) ?? ""; const messageId = firstNonEmptyString((message as Record).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 }; + return { ...message, role, text, id: messageId, messageId, title: normalizeWorkbenchMessageTitle(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 | null { diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts index 25255e54..f61083c2 100644 --- a/web/hwlab-cloud-web/src/types/global.d.ts +++ b/web/hwlab-cloud-web/src/types/global.d.ts @@ -1,20 +1,8 @@ -import type { WorkspaceRecord } from "./index"; - -export interface WorkbenchEarlyWorkspaceBootstrap { - ok: boolean; - status: number; - data: unknown; - error?: string; - projectId?: string | null; -} - declare global { interface Window { HWLAB_CLOUD_WEB_CONFIG?: { auth?: { mode?: "auto" | "manual"; username?: string; password?: string }; }; - HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP?: Promise; - HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?: { projectId: string; workspace: WorkspaceRecord }; } } diff --git a/web/hwlab-cloud-web/src/utils/index.ts b/web/hwlab-cloud-web/src/utils/index.ts index 20e2d6aa..1d37864e 100644 --- a/web/hwlab-cloud-web/src/utils/index.ts +++ b/web/hwlab-cloud-web/src/utils/index.ts @@ -31,8 +31,14 @@ export function normalizeWorkbenchConversationId(value: unknown): string | null return /^cnv_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; } -export function workbenchSessionPath(conversationId: string): string { - return `/workbench/sessions/${encodeURIComponent(conversationId)}`; +export function normalizeWorkbenchSessionId(value: unknown): string | null { + const text = nonEmptyString(value); + if (!text) return null; + return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; +} + +export function workbenchSessionPath(sessionId: string): string { + return `/workbench/sessions/${encodeURIComponent(sessionId)}`; } export function resolveInitialWorkbenchProjectId(): string { diff --git a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue index f89ae7e9..9c1dffc1 100644 --- a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue +++ b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue @@ -9,67 +9,68 @@ import ConversationPanel from "@/components/workbench/ConversationPanel.vue"; import SessionRail from "@/components/workbench/SessionRail.vue"; import HwpodNodeOpsPanel from "@/components/hwpod/HwpodNodeOpsPanel.vue"; import { useAutoRefresh } from "@/composables/useAutoRefresh"; -import { shouldReflectWorkbenchConversationUrl } from "@/router/workbench-navigation"; +import { shouldReflectWorkbenchSessionUrl } from "@/router/workbench-navigation"; import { useWorkbenchStore } from "@/stores/workbench"; -import { normalizeWorkbenchConversationId, normalizeWorkbenchProjectId } from "@/utils"; +import { normalizeWorkbenchConversationId, normalizeWorkbenchSessionId } from "@/utils"; import { finishWorkbenchOpenFullLoad, startWorkbenchOpenJourney } from "@/utils/workbench-performance"; const workbench = useWorkbenchStore(); const route = useRoute(); const router = useRouter(); -const routeConversationId = computed(() => normalizeWorkbenchConversationId(route.params.conversationId)); -const applyingRouteConversation = ref(Boolean(routeConversationId.value)); +const routeSessionId = computed(() => normalizeWorkbenchSessionId(route.params.sessionId)); +const routeLegacyConversationId = computed(() => normalizeWorkbenchConversationId(route.params.sessionId)); +const applyingRouteSession = ref(Boolean(routeSessionId.value || routeLegacyConversationId.value)); const componentActive = ref(false); onMounted(async () => { componentActive.value = true; - applyingRouteConversation.value = Boolean(routeConversationId.value); - startWorkbenchOpenJourney({ route: route.path, cache: routeConversationId.value ? "cold" : "warm", authState: "warm" }); + applyingRouteSession.value = Boolean(routeSessionId.value || routeLegacyConversationId.value); + startWorkbenchOpenJourney({ route: route.path, cache: routeSessionId.value || routeLegacyConversationId.value ? "cold" : "warm", authState: "warm" }); try { - await workbench.hydrate({ conversationId: routeConversationId.value }); - await applyRouteConversation(); + await workbench.hydrate({ sessionId: routeSessionId.value, conversationId: routeLegacyConversationId.value }); + await applyRouteSession(); finishWorkbenchOpenFullLoad(workbench.error ? "error" : "ok"); } finally { - applyingRouteConversation.value = false; + applyingRouteSession.value = false; } - if (!routeConversationId.value || routeConversationId.value === workbench.activeConversationId) await reflectActiveConversationInUrl(workbench.activeConversationId); + if (!routeSessionId.value || routeSessionId.value === workbench.activeSessionId) await reflectActiveSessionInUrl(workbench.activeSessionId); }); onBeforeUnmount(() => { componentActive.value = false; }); -watch(routeConversationId, () => void applyRouteConversation()); -watch(() => workbench.activeConversationId, (conversationId) => void reflectActiveConversationInUrl(conversationId)); +watch(() => route.params.sessionId, () => void applyRouteSession()); +watch(() => workbench.activeSessionId, (sessionId) => void reflectActiveSessionInUrl(sessionId)); useAutoRefresh(() => workbench.refreshLive(), 30_000); -async function applyRouteConversation(): Promise { - const conversationId = routeConversationId.value; - if (!conversationId) return true; - applyingRouteConversation.value = true; +async function applyRouteSession(): Promise { + const sessionId = routeSessionId.value; + const legacyConversationId = routeLegacyConversationId.value; + if (!sessionId && !legacyConversationId) return true; + applyingRouteSession.value = true; try { - return await workbench.selectConversationById(conversationId); + return sessionId ? await workbench.selectSessionById(sessionId) : await workbench.selectConversationById(legacyConversationId ?? ""); } finally { - applyingRouteConversation.value = false; - await reflectActiveConversationInUrl(workbench.activeConversationId); + applyingRouteSession.value = false; + await reflectActiveSessionInUrl(workbench.activeSessionId); } } -async function reflectActiveConversationInUrl(value: string | null): Promise { - const conversationId = normalizeWorkbenchConversationId(value); - const routeTarget = routeConversationId.value; - if (!shouldReflectWorkbenchConversationUrl({ +async function reflectActiveSessionInUrl(value: string | null): Promise { + const sessionId = normalizeWorkbenchSessionId(value); + const routeTarget = routeSessionId.value; + if (!shouldReflectWorkbenchSessionUrl({ componentActive: componentActive.value, routeName: route.name, routePath: route.path, routeSection: route.meta.section, - routeConversationId: routeTarget, - conversationId, - applyingRouteConversation: applyingRouteConversation.value + routeSessionId: routeTarget, + sessionId, + applyingRouteSession: applyingRouteSession.value })) return; - const projectId = normalizeWorkbenchProjectId(route.query.projectId) ?? normalizeWorkbenchProjectId(workbench.workspace?.projectId) ?? normalizeWorkbenchProjectId(workbench.workspace?.workspace?.projectId); await router.replace({ name: "CodeWorkbenchSession", - params: { conversationId }, - query: projectId ? { projectId } : {} + params: { sessionId }, + query: {} }); }