Files
吉鑫升andClaude Fable 5 28098b3155 feat: 知识库实体筛选、技能白名单收紧、嵌入主题与提问组件优化
- 知识库笔记列表支持按实体精确筛选(路由/服务/仓储层透传,关系图索引查询)
- 自定义智能体技能白名单在 skill_view 与 skill_manage 的 list/view 别名上同样生效;
  config 未声明 skills 时视为无可见技能
- 嵌入圆桌主题解析默认深蓝,强制主题期间不再写 localStorage 覆盖主站选择
- 智能体提问建议拆出 agent-question-chips 组件,编辑器与详情面板联动优化
- 新增 qa-client 问答测试脚本

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:09:32 +08:00

172 lines
5.6 KiB
TypeScript

"use client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import { resolveEmbedChatParams } from "@/core/embed/params";
import { registerEmbedSession } from "@/core/embed/status-api";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { ChatBox } from "@/components/workspace/chats";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { useModels } from "@/core/models/hooks";
import { cn } from "@/lib/utils";
const EMBED_ROUTE_BASE = "/embed/chats";
export default function EmbedChatPage() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
// 主题不在这里处理 —— ?theme= 由 AppProviders 通过 forcedTheme 强制
// (不落 localStorage),避免污染主站用户自己选的主题。
const embedParams = resolveEmbedChatParams(searchParams.toString());
const [authReady, setAuthReady] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [threadId, setThreadId] = useState(() =>
threadIdFromPath === "new" ? uuid() : (threadIdFromPath ?? uuid()),
);
const [isNewThread, setIsNewThread] = useState(threadIdFromPath === "new");
const hasAutoSentRef = useRef(false);
const lastRegisteredThreadRef = useRef<string | null>(null);
useEffect(() => {
if (threadIdFromPath === "new" || !threadIdFromPath) {
return;
}
setIsNewThread(false);
setThreadId(threadIdFromPath);
}, [threadIdFromPath]);
const [settings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
useEffect(() => {
let cancelled = false;
async function runLogin() {
setAuthError(null);
try {
const auth = await loginByUsername(embedParams.username);
if (cancelled) return;
setStoredAuth(auth);
setAuthReady(true);
} catch (error) {
if (cancelled) return;
setAuthError(
error instanceof Error ? error.message : "Login request failed.",
);
}
}
void runLogin();
return () => {
cancelled = true;
};
}, [embedParams.username]);
const registerSessionForThread = useCallback(
(resolvedThreadId: string) => {
if (!embedParams.sessionId || lastRegisteredThreadRef.current === resolvedThreadId) {
return;
}
lastRegisteredThreadRef.current = resolvedThreadId;
void registerEmbedSession(embedParams.sessionId, resolvedThreadId).catch(
(error) => {
console.error("[embed] failed to register session mapping", error);
lastRegisteredThreadRef.current = null;
},
);
},
[embedParams.sessionId],
);
const [thread, sendMessage] = useThreadStream({
threadId: authReady && !isNewThread ? threadId : undefined,
context: settings.context,
isMock: false,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
registerSessionForThread(createdThreadId);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${EMBED_ROUTE_BASE}/${createdThreadId}`,
);
},
});
useEffect(() => {
if (!authReady || !threadId) {
return;
}
registerSessionForThread(threadId);
}, [authReady, threadId, registerSessionForThread]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
void sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
);
useEffect(() => {
if (!authReady || !embedParams.message || hasAutoSentRef.current) {
return;
}
hasAutoSentRef.current = true;
void sendMessage(threadId, { text: embedParams.message, files: [] });
}, [authReady, embedParams.message, sendMessage, threadId]);
if (!embedParams.sessionId) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-center text-sm text-red-600">
缺少参数 sessionId:父页面需在 iframe URL 中传入由己方生成的会话 id,用于查询问答是否结束。
</div>
);
}
if (authError) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-red-600">
登录失败:{authError}
</div>
);
}
if (!authReady) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-muted-foreground">
正在进入问答…
</div>
);
}
return (
<ThreadContext.Provider value={{ thread, isMock: false }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0">
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full")}
threadId={threadId}
thread={thread}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}