Files
deerflow-code/frontend-web/src/core/threads/utils.ts
T
董宏凯andCursor b81d200eca fix: 协助卡停轮与提交防 409,隐藏配置向导会话,并限制岗位会商产物重复写入
澄清中间件改为 ToolMessage + after_model 停轮,前端提交前取消残留 run;系统/向导 thread_type 从对话列表排除;岗位总结席位加 write_file 上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 20:29:59 +08:00

181 lines
5.9 KiB
TypeScript

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<AgentThreadContext, "agent_id" | "agent_name">;
type ThreadRouteTarget =
| string
| {
thread_id: string;
context?: ThreadAgentRef | null;
metadata?: Record<string, unknown> | null;
};
function pickAgentRef(
context: ThreadAgentRef | null | undefined,
metadata: Record<string, unknown> | 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;
}
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)
: pickAgentRef(thread.context, thread.metadata);
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<string, unknown> | 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<string, unknown> | null;
}): boolean {
const threadType = thread.metadata?.thread_type;
return threadType === "writing_setup" || threadType === "report_structure_setup";
}
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
export function isHiddenFromRecentChats(thread: {
metadata?: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | null)?.is_pinned === true;
}
export function isThreadFavorite(thread: AgentThread): boolean {
return (thread.metadata as Record<string, unknown> | 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";
}