fix: make workbench session route authoritative

This commit is contained in:
lyon
2026-06-18 12:14:57 +08:00
parent c24f0d8124
commit 3a50fec856
17 changed files with 295 additions and 232 deletions
-24
View File
@@ -5,30 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HWLAB 云工作台</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<script>
(function () {
var hash = window.location.hash;
var path = window.location.pathname.replace(/\/+$/g, "") || "/";
var isWorkbenchPath = path === "/" || path === "/workbench" || path === "/workspace" || /^\/(?:workbench|workspace)\/sessions\/cnv_[A-Za-z0-9_.:-]+$/.test(path);
if (!isWorkbenchPath) return;
if (hash && hash !== "#/workspace" && hash !== "#/") return;
var projectId = new URLSearchParams(window.location.search).get("projectId");
if (!projectId) {
try { projectId = window.localStorage.getItem("hwlab.workbench.projectId.v1"); } catch (_) { projectId = null; }
}
if (!/^prj_[A-Za-z0-9_.:-]{3,96}$/.test(projectId || "")) projectId = "prj_hwpod_workbench";
window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP = fetch("/v1/workbench/workspace?projectId=" + encodeURIComponent(projectId), {
credentials: "same-origin",
headers: { accept: "application/json" }
}).then(function (response) {
return response.json().catch(function () { return null; }).then(function (body) {
return { ok: response.ok, status: response.status, data: body, projectId: projectId };
});
}).catch(function (error) {
return { ok: false, status: 0, data: null, error: String(error && error.message ? error.message : error), projectId: projectId };
});
}());
</script>
</head>
<body data-auth-state="checking">
<div id="app"></div>
+4 -4
View File
@@ -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 id="app"><\/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");
@@ -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);
});
+1 -3
View File
@@ -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();
});
</script>
@@ -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);
+54 -1
View File
@@ -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<ApiResult<WorkbenchSessionListResponse>> => fetchJson(sessionListPath(options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_LIST_TIMEOUT_MS), timeoutName: "workbench sessions" }),
session: (sessionId: string, timeoutMs: number | null = null): Promise<ApiResult<WorkbenchSessionDetailResponse>> => fetchJson(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: requestTimeoutMs(timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" }),
sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise<ApiResult<WorkbenchMessagePageResponse>> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }),
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" }),
@@ -24,6 +60,23 @@ export const workbenchAPI = {
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 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() : "";
@@ -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();
}
@@ -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<ApiResult<WorkspaceBootstrapResponse> | 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<ApiResult<WorkspaceBootstrapResponse>> {
const normalizedProjectId = normalizeWorkbenchProjectId(projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID;
const earlyWorkspace = await consumeEarlyWorkspaceBootstrap(normalizedProjectId);
return resolveWorkspaceBootstrapFromCurrentApi(normalizedProjectId, earlyWorkspace);
}
async function resolveWorkspaceBootstrapFromCurrentApi(projectId: string, earlyWorkspace: ApiResult<WorkspaceBootstrapResponse> | null): Promise<ApiResult<WorkspaceBootstrapResponse>> {
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 };
}
+1 -8
View File
@@ -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, unknown> }): string {
const raw = to.query?.projectId;
const value = Array.isArray(raw) ? raw[0] : raw;
return normalizeWorkbenchProjectId(value) ?? DEFAULT_WORKBENCH_PROJECT_ID;
}
+1 -1
View File
@@ -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" } },
@@ -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;
}
+3 -14
View File
@@ -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<AuthState>("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<void> {
async function bootstrap(): Promise<void> {
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<ReturnType<typeof authAPI.login>>): string {
const code = loginErrorCode(response.data) ?? nonEmptyString(response.error);
const normalizedCode = code?.trim().toLowerCase().replace(/_/gu, "-") ?? "";
@@ -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<Locati
}
}
export function initialWorkbenchSessionIdFromLocation(location: Pick<Location, "pathname"> | 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;
}
+154 -53
View File
@@ -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<WorkspaceRecord | null>(window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?.workspace ?? null);
const workspace = ref<WorkspaceRecord | null>(null);
const conversations = ref<ConversationRecord[]>([]);
const messages = ref<ChatMessage[]>([]);
const providerProfile = ref<ProviderProfile>(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<string | null>(initialWorkbenchConversationIdFromLocation());
const explicitSessionId = ref<string | null>(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<void> {
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<boolean> {
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<void> {
const conversationId = activeConversationId.value;
if (!conversationId) return;
@@ -285,9 +315,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
async function refreshConversations(includeConversationId: string | null = currentListIncludeConversationId()): Promise<void> {
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<void> {
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<WorkspaceRecord | null> {
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<ConversationRecord | null> {
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<ConversationRecord | null> {
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<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)
const text = 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 };
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, TurnStatusAuthority>): string | null {
-12
View File
@@ -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<WorkbenchEarlyWorkspaceBootstrap>;
HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?: { projectId: string; workspace: WorkspaceRecord };
}
}
+8 -2
View File
@@ -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 {
@@ -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<boolean> {
const conversationId = routeConversationId.value;
if (!conversationId) return true;
applyingRouteConversation.value = true;
async function applyRouteSession(): Promise<boolean> {
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<void> {
const conversationId = normalizeWorkbenchConversationId(value);
const routeTarget = routeConversationId.value;
if (!shouldReflectWorkbenchConversationUrl({
async function reflectActiveSessionInUrl(value: string | null): Promise<void> {
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: {}
});
}
</script>