Merge pull request #1309 from pikasTech/fix/issue-1294-session-url
fix(web): add workbench session deep links
This commit is contained in:
@@ -22,7 +22,7 @@ const CLIENT_DISCONNECT_ERROR_CODES = new Set([
|
||||
]);
|
||||
const CLIENT_DISCONNECT_ERROR_PATTERN = /\b(?:socket hang up|client disconnected|premature close)\b/iu;
|
||||
const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-handler-installed");
|
||||
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
|
||||
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
|
||||
const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u;
|
||||
|
||||
export async function serveCloudWeb(options) {
|
||||
@@ -68,7 +68,7 @@ export function createCloudWebServer({
|
||||
}
|
||||
|
||||
const routePath = normalizeCloudWebRoutePath(url.pathname);
|
||||
const relativePath = routePath === "/" || CLIENT_ROUTE_FALLBACK_PATHS.has(routePath) || isCloudWebClientRouteRequest(request, routePath)
|
||||
const relativePath = routePath === "/" || CLIENT_ROUTE_FALLBACK_PATHS.has(routePath) || isCloudWebWorkbenchSessionRoute(routePath) || isCloudWebClientRouteRequest(request, routePath)
|
||||
? "index.html"
|
||||
: url.pathname.slice(1);
|
||||
for (const root of roots) {
|
||||
@@ -115,6 +115,10 @@ export function createCloudWebServer({
|
||||
});
|
||||
}
|
||||
|
||||
function isCloudWebWorkbenchSessionRoute(routePath) {
|
||||
return /^\/(?:workbench|workspace)\/sessions\/cnv_[A-Za-z0-9_.:-]+$/u.test(routePath);
|
||||
}
|
||||
|
||||
function normalizeCloudWebRoutePath(pathname) {
|
||||
return String(pathname || "/").replace(/\/+$/u, "") || "/";
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
(function () {
|
||||
var hash = window.location.hash;
|
||||
var path = window.location.pathname.replace(/\/+$/g, "") || "/";
|
||||
if (path !== "/" && path !== "/workspace") return;
|
||||
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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ export const workbenchAPI = {
|
||||
updateWorkspace: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }),
|
||||
selectConversation: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }),
|
||||
conversations: (projectId: string): Promise<ApiResult<{ conversations?: ConversationRecord[] }>> => fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "conversations" }),
|
||||
conversation: (conversationId: string): Promise<ApiResult<{ conversation?: ConversationRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { timeoutMs: 8000, timeoutName: "conversation" }),
|
||||
deleteConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }),
|
||||
saveConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" })
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ const navSections = [
|
||||
];
|
||||
|
||||
const shellless = computed(() => route.name === "Login" || route.name === "Register" || route.name === "NotFound");
|
||||
const showWorkbenchDiagnostics = computed(() => route.name === "CodeWorkbench");
|
||||
const showWorkbenchDiagnostics = computed(() => route.meta.section === "workbench");
|
||||
|
||||
watch(showWorkbenchDiagnostics, (visible) => {
|
||||
if (!visible) diagnosticsOpen.value = false;
|
||||
@@ -38,6 +38,10 @@ async function go(path: string): Promise<void> {
|
||||
window.setTimeout(() => app.setLoading(false), 120);
|
||||
}
|
||||
}
|
||||
|
||||
function isNavItemActive(item: { name: string }): boolean {
|
||||
return route.name === item.name || (item.name === "CodeWorkbench" && route.meta.section === "workbench");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -56,7 +60,7 @@ async function go(path: string): Promise<void> {
|
||||
<nav class="nav-groups" aria-label="主导航">
|
||||
<section v-for="section in navSections" :key="section.title" class="nav-section">
|
||||
<h2>{{ section.title }}</h2>
|
||||
<button v-for="item in section.items" :key="item.name" class="nav-item" :data-active="route.name === item.name" type="button" :title="item.label" :aria-label="item.label" @click="go(item.path)">
|
||||
<button v-for="item in section.items" :key="item.name" class="nav-item" :data-active="isNavItemActive(item)" type="button" :title="item.label" :aria-label="item.label" @click="go(item.path)">
|
||||
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { useWorkbenchStore } from "@/stores/workbench";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
import { DEFAULT_WORKBENCH_PROJECT_ID, normalizeWorkbenchProjectId, workbenchSessionPath } from "@/utils";
|
||||
|
||||
const workbench = useWorkbenchStore();
|
||||
const { copied, copy } = useClipboard();
|
||||
@@ -12,7 +13,14 @@ const activeTab = computed(() => workbench.sessionTabs.find((tab) => tab.active)
|
||||
function copyActive(): void {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
void copy(`conversation=${tab.conversationId}\nsession=${tab.sessionId ?? ''}\nthread=${tab.threadId ?? ''}\ntrace=${tab.lastTraceId ?? ''}`);
|
||||
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 && projectId !== DEFAULT_WORKBENCH_PROJECT_ID) url.searchParams.set("projectId", projectId);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function startResize(event: PointerEvent): void {
|
||||
|
||||
@@ -30,7 +30,7 @@ export function installRouterGuards(router: Router): void {
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.checked) await auth.bootstrap();
|
||||
if (to.meta.requiresAuth !== false && auth.authState === "login") return { name: "Login" };
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
||||
import { installRouterGuards } from "./guards";
|
||||
|
||||
const CodeWorkbenchView = () => import("@/views/workbench/CodeWorkbenchView.vue");
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: "/", redirect: "/workbench" },
|
||||
{ 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", alias: ["/workspace"], name: "CodeWorkbench", component: () => import("@/views/workbench/CodeWorkbenchView.vue"), meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
|
||||
{ path: "/workbench/sessions/:conversationId", alias: ["/workspace/sessions/:conversationId"], 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" } },
|
||||
{ path: "/billing", name: "Billing", component: () => import("@/views/user/BillingView.vue"), meta: { requiresAuth: true, title: "充值与订阅", section: "user" } },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineStore } from "pinia";
|
||||
import { api } from "@/api";
|
||||
import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, 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, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
|
||||
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
|
||||
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session";
|
||||
|
||||
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
|
||||
@@ -119,6 +119,31 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
error.value = response.error ?? "session switch failed";
|
||||
}
|
||||
|
||||
async function selectConversationById(conversationId: string): Promise<boolean> {
|
||||
const normalized = normalizeWorkbenchConversationId(conversationId);
|
||||
if (!normalized) {
|
||||
error.value = "invalid session URL";
|
||||
return false;
|
||||
}
|
||||
const current = await ensureWorkspace();
|
||||
if (!current) return false;
|
||||
const existing = visibleConversations.value.find((conversation) => conversation.conversationId === normalized) ?? null;
|
||||
if (existing) {
|
||||
if (activeConversationId.value === normalized && messages.value.length > 0) return true;
|
||||
await selectConversation(existing);
|
||||
return activeConversationId.value === normalized;
|
||||
}
|
||||
const response = await api.workbench.conversation(normalized);
|
||||
const conversation = response.data?.conversation ?? null;
|
||||
if (!response.ok || !conversation) {
|
||||
error.value = response.error ?? "session URL not found";
|
||||
return false;
|
||||
}
|
||||
conversations.value = [conversation, ...conversations.value.filter((item) => item.conversationId !== normalized)];
|
||||
await selectConversation(conversation);
|
||||
return activeConversationId.value === normalized;
|
||||
}
|
||||
|
||||
async function deleteCurrentSession(): Promise<void> {
|
||||
const conversationId = activeConversationId.value;
|
||||
if (!conversationId) return;
|
||||
@@ -440,7 +465,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
return true;
|
||||
}
|
||||
|
||||
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
|
||||
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
|
||||
});
|
||||
|
||||
function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: ConversationRecord, projectId: string): WorkspaceRecord {
|
||||
|
||||
@@ -25,6 +25,16 @@ export function normalizeWorkbenchProjectId(value: unknown): string | null {
|
||||
return /^prj_[A-Za-z0-9_.:-]{3,96}$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
export function normalizeWorkbenchConversationId(value: unknown): string | null {
|
||||
const text = nonEmptyString(value);
|
||||
if (!text) return 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 resolveInitialWorkbenchProjectId(): string {
|
||||
const fromUrl = typeof window !== "undefined" ? normalizeWorkbenchProjectId(new URLSearchParams(window.location.search).get("projectId")) : null;
|
||||
if (fromUrl) return fromUrl;
|
||||
|
||||
@@ -1,15 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import CommandComposer from "@/components/workbench/CommandComposer.vue";
|
||||
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 { useWorkbenchStore } from "@/stores/workbench";
|
||||
import { DEFAULT_WORKBENCH_PROJECT_ID, normalizeWorkbenchConversationId, normalizeWorkbenchProjectId } from "@/utils";
|
||||
|
||||
const workbench = useWorkbenchStore();
|
||||
onMounted(() => void workbench.hydrate());
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const routeConversationId = computed(() => normalizeWorkbenchConversationId(route.params.conversationId));
|
||||
const applyingRouteConversation = ref(Boolean(routeConversationId.value));
|
||||
|
||||
onMounted(async () => {
|
||||
applyingRouteConversation.value = Boolean(routeConversationId.value);
|
||||
try {
|
||||
await workbench.hydrate();
|
||||
await applyRouteConversation();
|
||||
} finally {
|
||||
applyingRouteConversation.value = false;
|
||||
}
|
||||
if (!routeConversationId.value || routeConversationId.value === workbench.activeConversationId) await reflectActiveConversationInUrl(workbench.activeConversationId);
|
||||
});
|
||||
watch(routeConversationId, () => void applyRouteConversation());
|
||||
watch(() => workbench.activeConversationId, (conversationId) => void reflectActiveConversationInUrl(conversationId));
|
||||
useAutoRefresh(() => workbench.refreshLive(), 30_000);
|
||||
|
||||
async function applyRouteConversation(): Promise<boolean> {
|
||||
const conversationId = routeConversationId.value;
|
||||
if (!conversationId || workbench.activeConversationId === conversationId) return true;
|
||||
applyingRouteConversation.value = true;
|
||||
try {
|
||||
return await workbench.selectConversationById(conversationId);
|
||||
} finally {
|
||||
applyingRouteConversation.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reflectActiveConversationInUrl(value: string | null): Promise<void> {
|
||||
const conversationId = normalizeWorkbenchConversationId(value);
|
||||
if (!conversationId) return;
|
||||
const routeTarget = routeConversationId.value;
|
||||
if (applyingRouteConversation.value && routeTarget && routeTarget !== conversationId) return;
|
||||
if (routeTarget === conversationId) 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 !== DEFAULT_WORKBENCH_PROJECT_ID ? { projectId } : {}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user