- 知识库笔记列表支持按实体精确筛选(路由/服务/仓储层透传,关系图索引查询) - 自定义智能体技能白名单在 skill_view 与 skill_manage 的 list/view 别名上同样生效; config 未声明 skills 时视为无可见技能 - 嵌入圆桌主题解析默认深蓝,强制主题期间不再写 localStorage 覆盖主站选择 - 智能体提问建议拆出 agent-question-chips 组件,编辑器与详情面板联动优化 - 新增 qa-client 问答测试脚本 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
172 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|