澄清中间件改为 ToolMessage + after_model 停轮,前端提交前取消残留 run;系统/向导 thread_type 从对话列表排除;岗位总结席位加 write_file 上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
5.9 KiB
TypeScript
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";
|
|
}
|