import { useContext } from "react"; import type { Message } from "@langchain/langgraph-sdk"; import { RouteBaseContext } from "@/core/route-base"; import type { AgentThread, AgentThreadContext } from "./types"; type ThreadAgentRef = Pick; type ThreadRouteTarget = | string | { thread_id: string; context?: ThreadAgentRef | null; metadata?: Record | null; }; function pickAgentRef( context: ThreadAgentRef | null | undefined, metadata: Record | null | undefined, ): string | undefined { if (context?.agent_id) return context.agent_id; if (context?.agent_name) return context.agent_name; const metaId = metadata?.agent_id; if (typeof metaId === "string" && metaId) return metaId; const metaName = metadata?.agent_name; if (typeof metaName === "string" && metaName) return metaName; return undefined; } /** * Return the agent that owns a thread when that ownership was persisted on * either its run context or its metadata. Keeping this in one place ensures * sidebars and route recovery make the same decision for older deep links. */ export function agentRefOfThread(thread: { context?: ThreadAgentRef | null; metadata?: Record | null; }): string | undefined { return pickAgentRef(thread.context, thread.metadata); } export function pathOfThread( thread: ThreadRouteTarget, context?: ThreadAgentRef | null, base = "/page/workspace", ) { const threadId = typeof thread === "string" ? thread : thread.thread_id; const agentRef = typeof thread === "string" ? pickAgentRef(context, null) : agentRefOfThread(thread) ?? pickAgentRef(context, null); return agentRef ? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` : `${base}/chats/${threadId}`; } export function usePathOfThread() { const base = useContext(RouteBaseContext); return (thread: ThreadRouteTarget, context?: ThreadAgentRef | null) => pathOfThread(thread, context, base); } export function textOfMessage(message: Message) { if (typeof message.content === "string") { return message.content; } else if (Array.isArray(message.content)) { for (const part of message.content) { if (part.type === "text") { return part.text; } } } return null; } export function titleOfThread(thread: AgentThread, fallback = "Untitled") { const title = thread.values?.title; if (title) return title; // No title yet (e.g. the very first turn is still streaming, or the backend // title pass hasn't finished). Derive a sensible display title from the first // user message so the user never sees a bare "Untitled" placeholder. const messages = thread.values?.messages; if (Array.isArray(messages)) { const firstHuman = messages.find((m) => m.type === "human"); const text = firstHuman ? textOfMessage(firstHuman)?.trim() : null; if (text) { return text.length > 50 ? `${text.slice(0, 50).trimEnd()}…` : text; } } return fallback; } /** * 智能体创建过程产生的「引导会话」(bootstrap)。这类会话用 metadata.thread_type * === "bootstrap" 标记,点击后应回到「创建智能体」页继续,而不是普通问答页。 */ export function isBootstrapThread(thread: { metadata?: Record | null; }): boolean { return thread.metadata?.thread_type === "bootstrap"; } /** * 不应出现在普通对话列表里的 thread_type。 * 与后端 ``EXCLUDED_SYSTEM_THREAD_TYPES`` 保持同步;bootstrap 不在此列(侧栏仍展示)。 */ export const HIDDEN_FROM_RECENT_CHAT_TYPES = new Set([ "scheduler", "roundtable", "notebook", "deep_research", "writing_setup", "report_structure_setup", ]); export function isSetupWizardThread(thread: { metadata?: Record | null; }): boolean { const threadType = thread.metadata?.thread_type; return threadType === "writing_setup" || threadType === "report_structure_setup"; } /** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */ export function isHiddenFromRecentChats(thread: { metadata?: Record | null; }): boolean { const metadata = thread.metadata; if (metadata?.system === true) return true; const threadType = metadata?.thread_type; return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType); } /** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */ export function shouldHideSystemThreadsFromList( metadata?: Record | null, ): boolean { if (!metadata) return true; return !("thread_type" in metadata || "system" in metadata); } /** * bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在 * /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定, * 不随当前 route base 变化。 */ export function bootstrapResumePath(threadId: string): string { return `/page/workspace/agents/new/${threadId}`; } /** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */ export function listPathOfThread(thread: AgentThread): string { return isBootstrapThread(thread) ? bootstrapResumePath(thread.thread_id) : pathOfThread(thread); } export function isThreadPinned(thread: AgentThread): boolean { return (thread.metadata as Record | null)?.is_pinned === true; } export function isThreadFavorite(thread: AgentThread): boolean { return (thread.metadata as Record | null)?.is_favorite === true; } /** * Stable sort that lifts pinned (置顶) threads to the top while preserving the * incoming order (typically `updated_at` desc) within both the pinned and the * unpinned group. */ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] { const pinned: AgentThread[] = []; const rest: AgentThread[] = []; for (const thread of threads) { (isThreadPinned(thread) ? pinned : rest).push(thread); } return [...pinned, ...rest]; } 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"; }