This commit is contained in:
fengxiangdebaozi committed 2026-06-02 21:15:53 +08:00
commit fb43c036f6
23 files changed
+1355 -16

No files matched your search

+11
View File
@@ -17,6 +17,8 @@ import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
@@ -66,6 +68,15 @@ export function App() {
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
+46
View File
@@ -0,0 +1,46 @@
import { resolveThemeFromQuery } from "@/core/auth/external-login";
const DEFAULT_USERNAME = "guest";
export type EmbedChatParams = {
/** Parent correlation id — required for status polling without thread_id. */
sessionId: string;
message: string;
username: string;
theme: string;
};
/**
* Resolve iframe embed params from URL query only (plain iframe, not wujie).
*
* Parent must generate ``sessionId`` and pass it on the iframe URL so it can
* poll ``GET /api/public/embed/sessions/{sessionId}/status`` without reading
* the child frame.
*/
export function resolveEmbedChatParams(search: string): EmbedChatParams {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
const sessionId =
params.get("sessionId")?.trim() ||
params.get("session_id")?.trim() ||
"";
const message =
params.get("message")?.trim() ||
params.get("question")?.trim() ||
params.get("text")?.trim() ||
"";
const username =
params.get("username")?.trim() ||
params.get("userName")?.trim() ||
DEFAULT_USERNAME;
const theme = params.get("theme")?.trim() || "";
return { sessionId, message, username, theme };
}
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
return resolveThemeFromQuery(themeParam || null);
}
+52
View File
@@ -0,0 +1,52 @@
import { getBackendBaseURL } from "@/core/config";
export type EmbedSessionStatus = {
session_id: string;
registered: boolean;
thread_id: string | null;
finished: boolean;
thread_status: string;
has_active_run: boolean;
latest_run_status: string | null;
};
async function parseEmbedError(response: Response): Promise<string> {
const body = await response.json().catch(() => ({}));
if (typeof body.detail === "string") {
return body.detail;
}
return `HTTP ${response.status}`;
}
/** Register session_id → thread_id (iframe calls this; parent never needs thread_id). */
export async function registerEmbedSession(
sessionId: string,
threadId: string,
): Promise<{ session_id: string; thread_id: string }> {
const base = getBackendBaseURL();
const response = await fetch(`${base}/api/public/embed/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
body: JSON.stringify({ session_id: sessionId, thread_id: threadId }),
});
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<{ session_id: string; thread_id: string }>;
}
/** Poll whether embed Q&A has finished — parent uses sessionId only. */
export async function getEmbedSessionStatus(
sessionId: string,
): Promise<EmbedSessionStatus> {
const base = getBackendBaseURL();
const response = await fetch(
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
{ credentials: "omit" },
);
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<EmbedSessionStatus>;
}
+7
View File
@@ -24,6 +24,13 @@ export interface WujieProps {
env?: string;
/** 主题色标识(映射到深/浅色)。 */
theme?: string;
/** 嵌入问答:首条用户问题(与 question / text 同义)。 */
message?: string;
question?: string;
text?: string;
/** 嵌入问答:DeerFlow 本地用户名登录。 */
username?: string;
userName?: string;
[key: string]: unknown;
}
@@ -1,8 +1,10 @@
import { FileText, PenLine } from 'lucide-react';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
import { DraftStreamingPreview } from './DraftStreamingPreview';
import type { CanvasArtifactText } from '../../types/artifact';
function DraftWaitingPlaceholder() {
@@ -28,6 +30,27 @@ function DraftWaitingPlaceholder() {
export function AIWritingDraftPanel() {
const { artifact } = useCanvasGraphContext();
const { isDraftStreaming, draftStreamingMarkdown } = useCanvasUIContext();
// 流式生成中:只读流式预览,数据走独立通道(不依赖全局 artifact),全程不挂载编辑器,
// 彻底隔绝高频内容刷新触发的编辑器更新风暴。
if (isDraftStreaming) {
return (
<div className="h-full flex flex-col overflow-hidden border-l">
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1">草稿生成中…</span>
</div>
<div className="flex-1 overflow-hidden">
{draftStreamingMarkdown ? (
<DraftStreamingPreview markdown={draftStreamingMarkdown} />
) : (
<DraftWaitingPlaceholder />
)}
</div>
</div>
);
}
if (!artifact) {
return (
@@ -50,6 +73,7 @@ export function AIWritingDraftPanel() {
</div>
<div className="flex-1 overflow-hidden">
{/* 生成完成:带编辑器的渲染器,开放润色 / 滑词等编辑能力 */}
{content && !isCodeArtifact(content) ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : null}
@@ -41,7 +41,7 @@ import { InterventionCard } from './InterventionCard';
import { SkillStepsCard } from './SkillStepsCard';
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { DraftGeneratingView } from './DraftGeneratingView';
import { IntentResultCard } from './IntentResultCard';
import { IntentStreamingView } from './IntentStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
@@ -440,12 +440,9 @@ export function AIWritingPanel({
</div>
)}
{/* 草稿流式打字机视图 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
<DraftStreamingView
title={state.streamingDraftTitle}
sections={state.streamingDraftSections}
/>
{/* 草稿生成中:左侧只展示思考过程(若有)+「内容生成中」骨架屏;正文实时显示在右侧画布 */}
{state.status === 'writing_draft' && (
<DraftGeneratingView sections={state.streamingDraftSections} />
)}
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
@@ -485,12 +482,6 @@ export function AIWritingPanel({
</div>
)}
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
<div className="px-4 pb-4">
<WritingSkeletonCard status="writing_draft" />
</div>
)}
{isError && state.error && (
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{state.error}
@@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react';
import { Brain, PenLine } from 'lucide-react';
import type { StreamingDraftSection } from '../../types/ai-writing';
interface DraftGeneratingViewProps {
sections: StreamingDraftSection[];
}
/** 思考过程框:纯只读展示,跟随内容自动滚到底(仅操作 DOM,不触发 setState) */
function ThinkingBox({ text, done }: { text: string; done: boolean }) {
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [text]);
return (
<div className="rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30">
<Brain className="size-3 text-amber-500 shrink-0" />
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
</div>
<pre
ref={preRef}
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{text}
{!done && (
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
</div>
);
}
/** 「内容生成中」骨架屏:闪烁的占位行,提示正文正在右侧实时生成 */
function GeneratingSkeleton() {
return (
<div className="rounded-xl border bg-card shadow-sm p-4">
<div className="flex items-center gap-2 mb-3">
<PenLine className="size-3.5 text-primary animate-pulse" />
<span className="text-xs font-medium text-foreground">内容生成中…</span>
<span className="text-[11px] text-muted-foreground ml-auto">正文已在右侧实时显示</span>
</div>
<div className="space-y-2.5">
<div className="h-3 rounded bg-muted/70 animate-pulse w-[92%]" />
<div className="h-3 rounded bg-muted/70 animate-pulse w-full" />
<div className="h-3 rounded bg-muted/70 animate-pulse w-[85%]" />
<div className="h-3 rounded bg-muted/70 animate-pulse w-[70%]" />
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
/**
* 作家写作 / 按意见修改阶段,左侧的轻量「生成中」视图。
*
* 刻意不再逐章节渲染流式正文(那套 DraftStreamingView 含逐 chunk 的平滑
* scrollIntoView 等重型操作):正文已在右侧实时显示,左侧只需告知"正在生成",
* 若模型有思考过程则一并展示。这样左侧每个 chunk 的渲染开销极小,避免更新风暴。
*/
export function DraftGeneratingView({ sections }: DraftGeneratingViewProps) {
// 按章节顺序拼接非空思考内容;是否仍在思考以"存在未完成章节"近似
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
const thinking = ordered.map((s) => s.thinkingText).filter(Boolean).join('\n\n');
const thinkingDone = sections.length > 0 && sections.every((s) => s.isDone);
return (
<div className="px-4 pb-4 space-y-3">
{thinking && <ThinkingBox text={thinking} done={thinkingDone} />}
<GeneratingSkeleton />
</div>
);
}
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button";
interface DraftStreamingPreviewProps {
/** 正在流式生成的草稿 Markdown(边写边推送,逐 chunk 增长) */
markdown: string;
}
/**
* AI 写作右侧「边生成边推送」的只读流式预览。
*
* 刻意不挂载 Tiptap 编辑器(useWritingEditor / useWritingRewrite):草稿是 token 级
* 高频 chunk,若每个 chunk 都灌进编辑器会触发 ProseMirror transaction 风暴 + 父子组件
* 反复重渲染,导致 React「Maximum update depth exceeded」。生成完成后才由
* TextArtifactRenderer 接管为可编辑文档(润色 / 滑词等)。
*
* 行为:自动下滑跟随生成;用户向上滚动则停止自动下滑并浮出「回到底部」按钮,
* 点击后滚到底部并恢复自动下滑。
*/
export function DraftStreamingPreview({ markdown }: DraftStreamingPreviewProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
// 程序化滚动期间置 true,让 onScroll 忽略自己触发的滚动,避免 setState 抖动/回环
const programmaticRef = useRef(false);
const [showScrollBtn, setShowScrollBtn] = useState(false);
// 内容增长时,若自动下滑开启则贴底
useEffect(() => {
if (!autoScrollRef.current) return;
const el = scrollRef.current;
if (!el) return;
programmaticRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { programmaticRef.current = false; });
}, [markdown]);
const handleScroll = useCallback(() => {
if (programmaticRef.current) return; // 忽略程序化滚动
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;
setShowScrollBtn((prev) => (prev === !atBottom ? prev : !atBottom));
}, []);
const handleScrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) {
programmaticRef.current = true;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
requestAnimationFrame(() => { programmaticRef.current = false; });
}
autoScrollRef.current = true;
setShowScrollBtn(false);
}, []);
return (
<div className="relative h-full flex flex-col overflow-hidden">
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
<div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-[1.625rem] font-bold tracking-tight mb-3 mt-8 first:mt-0 text-foreground border-b border-border pb-2 leading-tight">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
{children}
</h3>
),
p: ({ children }) => (
<p className="mb-4 leading-[1.8] text-[15px] text-foreground indent-[2em]">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
{children}
</ol>
),
li: ({ children }) => <li className="leading-7">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
{children}
</pre>
),
code: ({ className, children }) => {
const isBlock = Boolean(className?.startsWith("language-"));
return isBlock ? (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
{children}
</code>
) : (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
{children}
</code>
);
},
}}
>
{markdown}
</ReactMarkdown>
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
</div>
</div>
{showScrollBtn && (
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
<Button
size="sm"
variant="secondary"
className="pointer-events-auto shadow-md gap-1.5"
onClick={handleScrollToBottom}
>
<ArrowDown className="w-3.5 h-3.5" />
回到底部
</Button>
</div>
)}
</div>
);
}
@@ -17,12 +17,18 @@ export interface CanvasUIContextValue {
isChatCollapsed: boolean;
isEditing: boolean;
isWebSearchOpen: boolean;
/** AI 写作:右侧草稿是否正在流式生成。生成中右侧走只读流式视图(自动下滑、禁用编辑/润色/滑词),生成完才可编辑 */
isDraftStreaming: boolean;
/** AI 写作:流式生成中的草稿 Markdown。生成期间走这条独立通道(不写全局 artifact),避免高频刷新触发编辑器更新风暴 */
draftStreamingMarkdown: string;
selection: CanvasSelectionState;
/** Tiptap editor selection for AI rewrite flow */
writingSelection: WritingSelection | null;
setIsChatCollapsed: (v: boolean) => void;
setIsEditing: (v: boolean) => void;
setIsWebSearchOpen: (v: boolean) => void;
setIsDraftStreaming: (v: boolean) => void;
setDraftStreamingMarkdown: (v: string) => void;
/** Set text selection; clears any existing code selection. */
setTextSelection: (s: CanvasTextSelection | null) => void;
/** Set code selection; clears any existing text selection. */
@@ -42,6 +48,8 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isChatCollapsed, setIsChatCollapsed] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [isDraftStreaming, setIsDraftStreaming] = useState(false);
const [draftStreamingMarkdown, setDraftStreamingMarkdown] = useState('');
const [selection, setSelection] =
useState<CanvasSelectionState>(EMPTY_SELECTION);
const [writingSelection, setWritingSelection] =
@@ -61,11 +69,15 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
isChatCollapsed,
isEditing,
isWebSearchOpen,
isDraftStreaming,
draftStreamingMarkdown,
selection,
writingSelection,
setIsChatCollapsed,
setIsEditing,
setIsWebSearchOpen,
setIsDraftStreaming,
setDraftStreamingMarkdown,
setTextSelection,
setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION),
@@ -27,6 +27,7 @@ import { useStream } from '@langchain/langgraph-sdk/react';
import { getAPIClient } from '@/core/api';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../contexts/CanvasUIContext';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import type {
@@ -140,6 +141,26 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
}
}
// 把"正在流式生成"的草稿章节拼成 Markdown,用于边写边推送到右侧画布。
// 拼接格式与后端 writer_draft.py 的 _assemble() 对齐(# 文章标题 / ## 章节标题),
// 但这里是流式预览版:最终的 draft_ready 仍会用后端规范化后的完整 markdown 覆盖。
function assembleStreamingDraftMarkdown(
sections: AIWritingState['streamingDraftSections'],
title: string,
): string {
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
const body = ordered
.map((s) => {
const heading = s.sectionTitle ? `## ${s.sectionTitle}` : '';
const text = s.text ?? '';
return heading ? `${heading}\n\n${text}` : text;
})
.filter((part) => part.trim().length > 0)
.join('\n\n');
const head = title ? `# ${title}\n\n` : '';
return `${head}${body}`;
}
// 与老 hook 行为对齐的事件 reducer —— 直接从 useAIWriting.ts 移植,删掉 setArtifact 副作用
// (副作用单独提到 hook 顶层 useEffect 里处理)。
function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState {
@@ -270,11 +291,16 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
isDone: done,
});
}
const streamingTitle = prev.streamingDraftTitle || prev.outline?.title || '';
// 边写边推送:把已生成的章节实时拼成 Markdown 写进 currentDraftMarkdown,
// 触发顶层 useEffect 把内容同步到右侧画布(无需等 draft_ready)。
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
streamingDraftTitle: streamingTitle,
currentDraftMarkdown: assembleStreamingDraftMarkdown(sections, streamingTitle),
currentDraftTitle: streamingTitle || prev.currentDraftTitle,
};
}
@@ -615,6 +641,7 @@ type AIWritingValues = {
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const { setIsDraftStreaming, setDraftStreamingMarkdown } = useCanvasUIContext();
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
// useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌
@@ -706,8 +733,20 @@ export function useAIWriting() {
if (ev) dispatch({ type: 'handle', event: ev });
}, [stream.interrupt, state.sessionId]);
// 草稿实时同步到右侧 Artifact 面板(与老 hook 行为一致)
// 草稿同步到右侧面板:流式生成中与生成完成,走两条互斥通道,三个状态在同一 effect 内
// 原子更新(同一次 commit,React 18 批处理),杜绝"artifact 已刷新但 isDraftStreaming 仍滞后"
// 的时序窗口——那会让带编辑器的 TextArtifactRenderer 在高频 artifact 变化下挂载并触发更新风暴。
// - 生成中(writing_draft / revising):只更新轻量字符串通道 draftStreamingMarkdown,
// **完全不动全局 artifact**,右侧用纯只读组件流式显示,全程不挂载编辑器。
// - 生成完成(draft_ready 后):清空流式通道,把最终草稿写入全局 artifact 供编辑器编辑。
useEffect(() => {
const streaming = state.status === 'writing_draft' || state.status === 'revising';
setIsDraftStreaming(streaming);
if (streaming) {
setDraftStreamingMarkdown(state.currentDraftMarkdown);
return;
}
setDraftStreamingMarkdown('');
if (state.currentDraftMarkdown) {
setArtifact({
currentIndex: 0,
@@ -719,7 +758,7 @@ export function useAIWriting() {
}],
});
}
}, [state.currentDraftMarkdown, state.currentDraftTitle, setArtifact]);
}, [state.status, state.currentDraftMarkdown, state.currentDraftTitle, setArtifact, setIsDraftStreaming, setDraftStreamingMarkdown]);
// transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传)
useEffect(() => {
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import {
resolveEmbedChatParams,
resolveEmbedTheme,
} 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();
const { setTheme } = useTheme();
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(() => {
if (embedParams.theme) {
setTheme(resolveEmbedTheme(embedParams.theme));
}
}, [embedParams.theme, setTheme]);
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>
);
}