fix: make workbench session route authoritative
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
const nextConversations = stableConversationList(conversations.value, sessionConversations, selectedConversationId, selectedConversation);
|
||||
conversations.value = nextConversations;
|
||||
rememberConversationList(nextConversations);
|
||||
conversationsReady.value = true;
|
||||
void refreshSessionStatusAuthority(nextConversations);
|
||||
} else {
|
||||
if (selectedConversation) {
|
||||
nextConversations = mergeConversationIntoList(nextConversations, selectedConversation);
|
||||
conversations.value = nextConversations;
|
||||
rememberConversationDetail(selectedConversation);
|
||||
}
|
||||
conversationsReady.value = conversations.value.length > 0;
|
||||
error.value = conversations.value.length > 0 ? null : conversationsResult.error ?? "session list unavailable";
|
||||
}
|
||||
loading.value = false;
|
||||
await refreshProviderOptions();
|
||||
restartRealtime("hydrate");
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user