Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	frontend-web/src/components/workspace/messages/message-list-item.tsx
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py
#	offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py
This commit is contained in:
吉鑫升 committed 2026-06-03 19:01:45 +08:00
commit a1d460e220
133 files changed
+12413 -1239

No files matched your search

+19 -17
View File
@@ -2,26 +2,28 @@
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getAuthorizationHeaderValue } from "../auth";
import { getLangGraphBaseURL } from "../config";
import { sanitizeRunStreamOptions } from "./stream-mode";
import { getAuthorizationHeaderValue } from "../auth";
import { getLangGraphBaseURL } from "../config";
import { withCsrfHeaders } from "./csrf";
import { sanitizeRunStreamOptions } from "./stream-mode";
function createCompatibleClient(isMock?: boolean): LangGraphClient {
const client = new LangGraphClient({
apiUrl: getLangGraphBaseURL(isMock),
onRequest(_url, init) {
const headers = new Headers(init.headers ?? {});
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader);
}
return {
...init,
headers,
};
},
});
apiUrl: getLangGraphBaseURL(isMock),
onRequest(_url, init) {
const headers = withCsrfHeaders(init.headers);
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader);
}
return {
...init,
credentials: init.credentials ?? "include",
headers,
};
},
});
const originalRunStream = client.runs.stream.bind(client.runs);
client.runs.stream = ((threadId, assistantId, payload) =>
+7 -2
View File
@@ -5,6 +5,7 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getAuthorizationHeaderValue } from "../auth";
import { getCanvasLangGraphBaseURL } from "../config";
import { withCsrfHeaders } from "./csrf";
import { sanitizeRunStreamOptions } from "./stream-mode";
/**
@@ -19,12 +20,16 @@ function createCanvasClient(): LangGraphClient {
const client = new LangGraphClient({
apiUrl: getCanvasLangGraphBaseURL(),
onRequest(_url, init) {
const headers = new Headers(init.headers ?? {});
const headers = withCsrfHeaders(init.headers);
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader);
}
return { ...init, headers };
return {
...init,
credentials: init.credentials ?? "include",
headers,
};
},
});
+42
View File
@@ -0,0 +1,42 @@
const CSRF_COOKIE_NAME = "csrf_token";
const CSRF_HEADER_NAME = "X-CSRF-Token";
function readCookie(name: string): string | undefined {
if (typeof document === "undefined") {
return undefined;
}
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [rawName, ...rest] = cookie.trim().split("=");
if (rawName !== name) {
continue;
}
const rawValue = rest.join("=");
if (!rawValue) {
return undefined;
}
try {
return decodeURIComponent(rawValue);
} catch {
return rawValue;
}
}
return undefined;
}
export function getCsrfToken(): string | undefined {
const token = readCookie(CSRF_COOKIE_NAME)?.trim();
return token ? token : undefined;
}
export function withCsrfHeaders(headers?: HeadersInit): Headers {
const next = new Headers(headers ?? {});
const csrfToken = getCsrfToken();
if (csrfToken && !next.has(CSRF_HEADER_NAME)) {
next.set(CSRF_HEADER_NAME, csrfToken);
}
return next;
}
+17 -15
View File
@@ -1,18 +1,20 @@
import { getAuthorizationHeaderValue } from "@/core/auth";
function withAuthHeaders(headers?: HeadersInit): Headers {
const next = new Headers(headers ?? {});
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !next.has("Authorization")) {
next.set("Authorization", authHeader);
}
return next;
import { getAuthorizationHeaderValue } from "@/core/auth";
import { withCsrfHeaders } from "./csrf";
function withAuthHeaders(headers?: HeadersInit): Headers {
const next = withCsrfHeaders(headers);
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !next.has("Authorization")) {
next.set("Authorization", authHeader);
}
return next;
}
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
return fetch(input, {
...init,
headers: withAuthHeaders(init?.headers),
});
}
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
return fetch(input, {
...init,
credentials: init?.credentials ?? "include",
headers: withAuthHeaders(init?.headers),
});
}
@@ -13,6 +13,10 @@ export const EXTERNAL_LOGIN_STORAGE_KEYS = {
ticket: "login.ticket",
userId: "login.userId",
env: "login.env",
/** MaxKey getTokenByOnlineTicket 返回的 token */
maxkeyOnlineToken: "login.maxkeyOnlineToken",
/** consumer /cop/userDetailToken 返回的 data 整包(JSON) */
userDetailToken: "login.userDetailToken",
} as const;
export type ExternalLoginParams = {
@@ -0,0 +1,142 @@
import { requestCrudService } from "@/service/request-axios-service";
import {
EXTERNAL_LOGIN_STORAGE_KEYS,
persistExternalLoginParams,
} from "./external-login";
const AUTH_BASE_URL = (import.meta.env.VITE_AUTH_BASE_URL || "").replace(
/\/$/,
"",
);
const CONSUMER_URL = (import.meta.env.VITE_AIPI_CONSUMER_URL || "").replace(
/\/$/,
"",
);
/** MaxKey onlineTicket 换发的 token(后续请求可能用到) */
export const MAXKEY_ONLINE_TOKEN_STORAGE_KEY =
EXTERNAL_LOGIN_STORAGE_KEYS.maxkeyOnlineToken;
/** `/cop/userDetailToken` 返回的 data 整包(JWT 解析后的用户信息) */
export const USER_DETAIL_TOKEN_STORAGE_KEY =
EXTERNAL_LOGIN_STORAGE_KEYS.userDetailToken;
export type MaxkeyTokenByTicketResponse = {
code: number;
data: string;
message?: string;
};
export type UserDetailTokenPayload = {
sub?: string;
user_id?: string;
exp?: number;
expired?: boolean;
iat?: number;
institution?: string;
iss?: string;
locale?: string;
kid?: string;
jti?: string;
[key: string]: unknown;
};
type UserDetailTokenResponse = {
code?: number;
data?: UserDetailTokenPayload;
message?: string;
};
function persistMaxkeyOnlineToken(token: string): void {
try {
localStorage.setItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token);
} catch {
// 静默失败,不阻断登录
}
}
function persistUserDetailToken(data: UserDetailTokenPayload): void {
try {
localStorage.setItem(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data));
if (data.user_id) {
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id));
}
} catch {
// 静默失败
}
}
/**
* 无界 / 外部跳转:用 onlineTicket 换 MaxKey token,并拉取用户详情。
* - token 存入 localStorage(`login.maxkeyOnlineToken`)
* - userDetailToken 的 data 存入 localStorage(`login.userDetailToken`)
* @returns 用于 DeerFlow `loginByUsername` 的用户名(取自 data.sub)
*/
export async function resolveUsernameByOnlineTicket(
ticket: string,
): Promise<string> {
const onlineTicket = ticket.trim();
if (!onlineTicket) {
throw new Error("缺少 onlineTicket,无法完成无界登录。");
}
if (!AUTH_BASE_URL) {
throw new Error("未配置 VITE_AUTH_BASE_URL,无法换取 MaxKey token。");
}
if (!CONSUMER_URL) {
throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。");
}
persistExternalLoginParams({ ticket: onlineTicket });
const tokenRes = (await requestCrudService.get(
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
{ onlineTicket },
)) as unknown as MaxkeyTokenByTicketResponse;
console.log("[wujie] getTokenByOnlineTicket:", tokenRes);
if (tokenRes.code !== 200 || !tokenRes.data) {
throw new Error(
tokenRes.message || "getTokenByOnlineTicket 未返回有效 token。",
);
}
persistMaxkeyOnlineToken(tokenRes.data);
const detailRes = (await requestCrudService.get(
`${CONSUMER_URL}/cop/userDetailToken`,
{ ticket: onlineTicket },
)) as unknown as UserDetailTokenResponse;
console.log("[wujie] userDetailToken:", detailRes);
const userData = detailRes.data;
if (!userData) {
throw new Error("userDetailToken 未返回用户 data。");
}
persistUserDetailToken(userData);
const username = (userData.sub ?? "").trim();
if (!username) {
throw new Error("userDetailToken 中缺少 sub(用户名)。");
}
return username;
}
export function readMaxkeyOnlineToken(): string {
try {
return localStorage.getItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY) ?? "";
} catch {
return "";
}
}
export function readUserDetailToken(): UserDetailTokenPayload | null {
try {
const raw = localStorage.getItem(USER_DETAIL_TOKEN_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as UserDetailTokenPayload;
} catch {
return null;
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) {
}
}
/** 笔记本外部系统(Studio)API 基础地址,通过 VITE_NOTEBOOK_API_BASE_URL 配置。 */
/** 笔记本外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
export function getNotebookApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
+46
View File
@@ -0,0 +1,46 @@
import { resolveThemeFromQuery } from "@/core/auth/external-login";
const DEFAULT_USERNAME = "guest";
export type EmbedChatParams = {
/** Parent correlation id — required for status polling without thread_id. */
sessionId: string;
message: string;
username: string;
theme: string;
};
/**
* Resolve iframe embed params from URL query only (plain iframe, not wujie).
*
* Parent must generate ``sessionId`` and pass it on the iframe URL so it can
* poll ``GET /api/public/embed/sessions/{sessionId}/status`` without reading
* the child frame.
*/
export function resolveEmbedChatParams(search: string): EmbedChatParams {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
const sessionId =
params.get("sessionId")?.trim() ||
params.get("session_id")?.trim() ||
"";
const message =
params.get("message")?.trim() ||
params.get("question")?.trim() ||
params.get("text")?.trim() ||
"";
const username =
params.get("username")?.trim() ||
params.get("userName")?.trim() ||
DEFAULT_USERNAME;
const theme = params.get("theme")?.trim() || "";
return { sessionId, message, username, theme };
}
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
return resolveThemeFromQuery(themeParam || null);
}
+73
View File
@@ -0,0 +1,73 @@
import { getBackendBaseURL } from "@/core/config";
export type EmbedSessionStatus = {
session_id: string;
registered: boolean;
thread_id: string | null;
finished: boolean;
thread_status: string;
has_active_run: boolean;
latest_run_status: string | null;
};
export type EmbedSessionResult = EmbedSessionStatus & {
messages: Record<string, unknown>[];
generated_text: string;
generated_files: string[];
};
async function parseEmbedError(response: Response): Promise<string> {
const body = await response.json().catch(() => ({}));
if (typeof body.detail === "string") {
return body.detail;
}
return `HTTP ${response.status}`;
}
/** Register session_id → thread_id (iframe calls this; parent never needs thread_id). */
export async function registerEmbedSession(
sessionId: string,
threadId: string,
): Promise<{ session_id: string; thread_id: string }> {
const base = getBackendBaseURL();
const response = await fetch(`${base}/api/public/embed/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
body: JSON.stringify({ session_id: sessionId, thread_id: threadId }),
});
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<{ session_id: string; thread_id: string }>;
}
/** Poll whether embed Q&A has finished — parent uses sessionId only. */
export async function getEmbedSessionStatus(
sessionId: string,
): Promise<EmbedSessionStatus> {
const base = getBackendBaseURL();
const response = await fetch(
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
{ credentials: "omit" },
);
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<EmbedSessionStatus>;
}
/** Fetch full messages, generated text, and artifact paths — parent uses sessionId only. */
export async function getEmbedSessionResult(
sessionId: string,
): Promise<EmbedSessionResult> {
const base = getBackendBaseURL();
const response = await fetch(
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
{ credentials: "omit" },
);
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<EmbedSessionResult>;
}
+9 -2
View File
@@ -6,7 +6,7 @@
* 注入参数,子应用在 `window.$wujie.props` 上拿到**同一个对象**(无界在子应用
* bootstrap 之前就已注入,因此首帧即可读)。
*
* 本期范围:只「读出来 + 存起来 + 打印」,**不接入对方鉴权**。读出的
* ticket 在 LoginPage 会走 MaxKey + consumer 双接口换用户名并登录;读出的
* ticket/userId/env 复用 external-login 的 localStorage 存储(与 URL 跳转登录
* 同一套 key),theme 交给调用方(LoginPage)映射到 ThemeContext。
*/
@@ -16,7 +16,7 @@ import { persistExternalLoginParams } from "@/core/auth/external-login";
/** 无界父级注入的 props 形状。除已知字段外允许透传其余未知字段便于排查。 */
export interface WujieProps {
/** 第三方鉴权 ticket(本期仅存储待用,不消费)。 */
/** 第三方 onlineTicket(LoginPage 用于 MaxKey + userDetailToken 登录)。 */
ticket?: string;
/** 外部用户 id。 */
userId?: string;
@@ -24,6 +24,13 @@ export interface WujieProps {
env?: string;
/** 主题色标识(映射到深/浅色)。 */
theme?: string;
/** 嵌入问答:首条用户问题(与 question / text 同义)。 */
message?: string;
question?: string;
text?: string;
/** 嵌入问答:DeerFlow 本地用户名登录。 */
username?: string;
userName?: string;
[key: string]: unknown;
}
+21 -1
View File
@@ -20,6 +20,8 @@ interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:cla
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -29,6 +31,7 @@ type MessageGroup =
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
@@ -50,7 +53,8 @@ export function groupMessages<T>(
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval"
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval"
) {
return last;
}
@@ -88,6 +92,13 @@ export function groupMessages<T>(
type: "assistant:setup-agent-approval",
messages: [message],
});
} else if (isSetupWritingApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-writing-approval",
messages: [message],
});
} else {
const open = lastOpenGroup();
if (open) {
@@ -354,6 +365,15 @@ export function isSetupAgentApprovalToolMessage(message: Message) {
);
}
export function isSetupWritingApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_writing" &&
typeof message.additional_kwargs?.setup_writing_approval === "object" &&
message.additional_kwargs.setup_writing_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
+2 -2
View File
@@ -7,6 +7,8 @@ const CITE_RE = /\[\[cite:(\d+)\]\]/g;
export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
const sourceMap = new Map(sources.map((s) => [s.id, s.title]));
const citeCounter = new Map<number, number>();
let citeIndex = 0;
return (tree: Root) => {
visit(tree, "text", (node, index, parent) => {
@@ -14,8 +16,6 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
CITE_RE.lastIndex = 0;
const newNodes: ElementContent[] = [];
const citeCounter = new Map<number, number>();
let citeIndex = 0;
let last = 0;
let match: RegExpExecArray | null;
+68 -6
View File
@@ -7,7 +7,7 @@ function base(): string {
export async function fetchStudioToken(email: string): Promise<string> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(
`${apiBase}/api/getTokenByEmail?email=${encodeURIComponent(email)}`,
@@ -22,7 +22,7 @@ export async function fetchStudioToken(email: string): Promise<string> {
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
headers: { Authorization: `Bearer ${token}` },
@@ -36,7 +36,7 @@ export async function createSearchSpace(
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
method: "POST",
@@ -46,7 +46,47 @@ export async function createSearchSpace(
},
body: JSON.stringify({ name, description: "", citations_enabled: true }),
});
if (!res.ok) throw new Error(`创建失败:${res.status}`);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail || `创建失败:${res.status}`);
}
}
export async function uploadSourceText(
notebookId: number,
content: string,
title: string,
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }),
});
if (!res.ok) throw new Error(`上传失败:${res.status}`);
}
export async function deleteSearchSpace(id: number, token: string): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`删除失败:${res.status}`);
}
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(`重命名失败:${res.status}`);
}
export interface ChunkRead {
@@ -69,7 +109,7 @@ export async function fetchDocumentByChunk(
token: string,
): Promise<DocumentWithChunks> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/documents/by-chunk/${chunkId}`, {
headers: { Authorization: `Bearer ${token}` },
@@ -78,12 +118,34 @@ export async function fetchDocumentByChunk(
return res.json() as Promise<DocumentWithChunks>;
}
export async function uploadSourceFiles(
notebookId: number,
files: File[],
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const formData = new FormData();
formData.append("search_space_id", String(notebookId));
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${apiBase}/api/v1/documents/fileupload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) throw new Error(`上传失败:${res.status}`);
}
export async function fetchNotebookSources(
notebookId: number,
token: string,
): Promise<SourceItem[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(
`${apiBase}/api/v1/source/search-space/${notebookId}?limit=100`,
+9 -2
View File
@@ -12,6 +12,7 @@ export function useStudioNotebooks(): {
notebooks: StudioNotebook[];
isLoading: boolean;
isError: boolean;
token: string | undefined;
} {
const email = getStoredAuth()?.email ?? "";
@@ -35,6 +36,7 @@ export function useStudioNotebooks(): {
notebooks: (notebooksQuery.data ?? []).filter((nb) => nb.is_owner),
isLoading: tokenQuery.isLoading || notebooksQuery.isLoading,
isError: tokenQuery.isError || notebooksQuery.isError,
token: tokenQuery.data,
};
}
@@ -89,10 +91,15 @@ export function useNotebookChat(
const send = async (text: string, selectedSourceIds: number[]) => {
if (thread.isLoading || !token) return;
// API 需要 document.id,而不是 source.id
const toDocIds = (ids: number[]) =>
ids
.map((id) => sources.find((s) => s.id === id)?.document?.id)
.filter((id): id is number => id !== undefined);
const sourceIds =
selectedSourceIds.length > 0
? selectedSourceIds
: sources.map((s) => s.id).filter(Boolean);
? toDocIds(selectedSourceIds)
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
await thread.submit(
{ messages: [{ type: "human", content: text }] },
+6 -3
View File
@@ -350,9 +350,12 @@ export function useThreadStream({
// agent thread URL — otherwise fall back to the global new chat.
const hash = typeof window !== "undefined" ? window.location.hash : "";
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
const target = agentMatch
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
: "/page/workspace/chats/new";
const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
const target = agentMatch
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
: iframeMatch
? "/page/workspace/chats_iframe/new"
: "/page/workspace/chats/new";
// First navigate to a safe URL so the post-reload page lands on a
// valid route. Then full-reload so TanStack Query cache, context
// providers and any other state captured under the previous user's
+12
View File
@@ -80,3 +80,15 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
return fallback;
}
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
if (!title) return "Untitled";
const cleaned = title
.replace(/<\/?[^>\n]+>/g, " ")
.replace(/<[^>\n]*$/g, " ")
.replace(/\s+/g, " ")
.trim();
return cleaned || "Untitled";
}