代码提交
This commit is contained in:
1 parent
5901246030
commit
47faa9bcdf
7 files changed
+767
-227
No files matched your search
@@ -6,9 +6,10 @@ import {
|
|||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
} from 'lucide-react';
|
SparklesIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -16,9 +17,10 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
type AnchorHTMLAttributes,
|
type AnchorHTMLAttributes,
|
||||||
type ImgHTMLAttributes,
|
type ImgHTMLAttributes,
|
||||||
} from 'react';
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
import rehypeKatex from 'rehype-katex';
|
import rehypeKatex from 'rehype-katex';
|
||||||
|
|
||||||
import { Loader } from '@/components/ai-elements/loader';
|
import { Loader } from '@/components/ai-elements/loader';
|
||||||
@@ -838,7 +840,7 @@ function FrontendRagSourcesPanel({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
if (sources.length === 0) return null;
|
if (sources.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full">
|
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -944,14 +946,17 @@ function MultiRoundRagContent({
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||||
components: MarkdownContentProps['components'];
|
components: MarkdownContentProps['components'];
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(!trace.isComplete);
|
const [open, setOpen] = useState(!trace.isComplete);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!trace.isComplete) setOpen(true);
|
// Keep the active turn expanded so each new retrieval is visible as it
|
||||||
}, [trace.isComplete]);
|
// arrives, then match GeneralQA by collapsing the completed trace. The
|
||||||
|
// header still carries the total round count for quick scanning.
|
||||||
|
setOpen(!trace.isComplete);
|
||||||
|
}, [trace.isComplete]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
|
||||||
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
|
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -965,31 +970,42 @@ function MultiRoundRagContent({
|
|||||||
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">多轮检索过程</div>
|
<div className="text-sm font-medium">多轮检索思考过程</div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
{trace.isComplete
|
{trace.isComplete
|
||||||
? `已完成 ${trace.rounds.length} 轮检索`
|
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
|
||||||
: '正在分析问题并检索资料'}
|
: '正在分析问题并检索资料'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
|
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="border-border/60 space-y-3 border-t px-4 py-3">
|
<div className="border-border/60 space-y-3 border-t px-4 py-3">
|
||||||
{trace.rounds.map((round, index) => (
|
{trace.rounds.map((round, index) => (
|
||||||
<MultiRoundRagRoundCard
|
<div
|
||||||
key={round.id}
|
key={round.id}
|
||||||
index={index + 1}
|
className={cn(
|
||||||
round={round}
|
'relative pl-5',
|
||||||
isLoading={isLoading}
|
index < trace.rounds.length - 1 && 'pb-1',
|
||||||
rehypePlugins={rehypePlugins}
|
)}
|
||||||
components={components}
|
>
|
||||||
/>
|
{index < trace.rounds.length - 1 && (
|
||||||
))}
|
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
|
||||||
</div>
|
)}
|
||||||
|
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
|
||||||
|
<MultiRoundRagRoundCard
|
||||||
|
index={index + 1}
|
||||||
|
round={round}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={components}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="border-border/70 rounded-xl border p-4">
|
<section className="border-border/70 rounded-xl border p-4">
|
||||||
@@ -1014,7 +1030,7 @@ function MultiRoundRagContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MultiRoundRagRoundCard({
|
function MultiRoundRagRoundCard({
|
||||||
index,
|
index,
|
||||||
round,
|
round,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -1026,78 +1042,228 @@ function MultiRoundRagRoundCard({
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||||
components: MarkdownContentProps['components'];
|
components: MarkdownContentProps['components'];
|
||||||
}) {
|
}) {
|
||||||
return (
|
const [open, setOpen] = useState(!round.query || round.isSearching);
|
||||||
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
|
useEffect(() => {
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
// A newly created round stays expanded to expose its thought and loading
|
||||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
|
// query. Once that query has returned, retain only the round header until
|
||||||
{index}
|
// the user asks to inspect the completed detail again.
|
||||||
</span>
|
setOpen(!round.query || round.isSearching);
|
||||||
<span>{round.title}</span>
|
}, [round.id, round.isSearching, round.query]);
|
||||||
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
|
|
||||||
</div>
|
return (
|
||||||
{round.thought && (
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
|
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
|
||||||
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
|
<CollapsibleTrigger asChild>
|
||||||
<MarkdownContent
|
<button
|
||||||
content={round.thought}
|
type="button"
|
||||||
isLoading={isLoading}
|
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||||
rehypePlugins={rehypePlugins}
|
>
|
||||||
components={components}
|
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
||||||
className="text-muted-foreground text-sm"
|
{index}
|
||||||
/>
|
</span>
|
||||||
</section>
|
<span className="min-w-0 flex-1 truncate">{round.title}</span>
|
||||||
)}
|
{round.isSearching ? (
|
||||||
{round.body && (
|
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||||
<section className="border-border/50 mt-3 rounded-md border p-3">
|
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
|
||||||
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
|
检索中
|
||||||
<MarkdownContent
|
</span>
|
||||||
content={round.body}
|
) : (
|
||||||
isLoading={isLoading}
|
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
|
||||||
rehypePlugins={rehypePlugins}
|
)}
|
||||||
components={components}
|
<ChevronDownIcon
|
||||||
className="text-sm"
|
className={cn(
|
||||||
/>
|
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||||
</section>
|
!open && '-rotate-90',
|
||||||
)}
|
)}
|
||||||
{round.query && (
|
/>
|
||||||
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
|
</button>
|
||||||
<SearchIcon className="size-3 shrink-0" />
|
</CollapsibleTrigger>
|
||||||
<span className="truncate">{round.query}</span>
|
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
|
||||||
</div>
|
<div className="pt-3">
|
||||||
)}
|
{round.thought && (
|
||||||
{round.retrievals.length > 0 && (
|
<MultiRoundRagDetailSection
|
||||||
<section className="border-border/50 mt-3 rounded-md border p-3">
|
title="思考内容"
|
||||||
<div className="text-muted-foreground mb-2 text-xs font-medium">
|
icon={<SparklesIcon className="text-primary size-4" />}
|
||||||
检索内容 · {round.retrievals.length} 条
|
>
|
||||||
</div>
|
<MarkdownContent
|
||||||
<div className="space-y-2">
|
content={round.thought}
|
||||||
{round.retrievals.map((item, resultIndex) => (
|
isLoading={isLoading}
|
||||||
<details
|
rehypePlugins={rehypePlugins}
|
||||||
key={`${item.id ?? item.title}-${resultIndex}`}
|
components={components}
|
||||||
className="border-border/60 rounded-md border px-3 py-2"
|
className="text-muted-foreground text-sm"
|
||||||
open={resultIndex === 0}
|
/>
|
||||||
>
|
</MultiRoundRagDetailSection>
|
||||||
<summary className="cursor-pointer list-none text-sm font-medium">
|
)}
|
||||||
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
|
{round.body && (
|
||||||
{item.title}
|
<section className="border-border/50 mt-3 rounded-lg border p-3">
|
||||||
</summary>
|
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
|
||||||
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
|
<MarkdownContent
|
||||||
{item.content || '暂无资料摘要'}
|
content={round.body}
|
||||||
</div>
|
isLoading={isLoading}
|
||||||
{(item.source || item.time) && (
|
rehypePlugins={rehypePlugins}
|
||||||
<div className="text-muted-foreground mt-2 text-xs">
|
components={components}
|
||||||
{[item.source, item.time].filter(Boolean).join(' · ')}
|
className="text-sm"
|
||||||
</div>
|
/>
|
||||||
)}
|
</section>
|
||||||
</details>
|
)}
|
||||||
))}
|
{(round.query || round.isSearching) && (
|
||||||
</div>
|
<div
|
||||||
</section>
|
className={cn(
|
||||||
)}
|
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
|
||||||
</div>
|
round.isSearching && 'animate-pulse motion-reduce:animate-none',
|
||||||
);
|
)}
|
||||||
}
|
aria-live={round.isSearching ? 'polite' : undefined}
|
||||||
|
>
|
||||||
|
{round.isSearching ? (
|
||||||
|
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SearchIcon className="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 text-muted-foreground">检索词</span>
|
||||||
|
<span className="min-w-0 truncate font-medium">
|
||||||
|
{round.query || '正在生成检索词…'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(round.query || round.isSearching || round.retrievals.length > 0) && (
|
||||||
|
<MultiRoundRagDetailSection
|
||||||
|
defaultOpen={!round.isSearching}
|
||||||
|
title="检索内容"
|
||||||
|
icon={<FileIcon className="text-primary size-3.5" />}
|
||||||
|
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
|
||||||
|
>
|
||||||
|
{round.isSearching ? (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
|
||||||
|
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
|
||||||
|
正在召回相关内容…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{round.retrievals.length > 0 ? (
|
||||||
|
round.retrievals.map((item, resultIndex) => (
|
||||||
|
<MultiRoundRagResultCard
|
||||||
|
key={`${item.id ?? item.title}-${resultIndex}`}
|
||||||
|
item={item}
|
||||||
|
index={resultIndex + 1}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground py-1 text-sm">
|
||||||
|
本轮未召回相关资料。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MultiRoundRagDetailSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</article>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiRoundRagDetailSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
suffix,
|
||||||
|
defaultOpen = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
suffix?: ReactNode;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
|
||||||
|
{suffix}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||||
|
!open && '-rotate-90',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
|
||||||
|
{children}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</section>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiRoundRagResultCard({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
item: MultiRoundRagRound['retrievals'][number];
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(index === 1);
|
||||||
|
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||||
|
>
|
||||||
|
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
|
||||||
|
{index}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||||
|
!open && '-rotate-90',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
|
||||||
|
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
|
||||||
|
{item.content || '暂无资料摘要'}
|
||||||
|
</p>
|
||||||
|
{(sourceMeta || item.url) && (
|
||||||
|
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||||
|
{sourceMeta && <span>{sourceMeta}</span>}
|
||||||
|
{item.url && (
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
查看原文
|
||||||
|
<ExternalLinkIcon className="size-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSingleRoundRagSourceUrl(
|
function resolveSingleRoundRagSourceUrl(
|
||||||
source: RuntimeDisplaySource | null,
|
source: RuntimeDisplaySource | null,
|
||||||
|
|||||||
@@ -255,15 +255,15 @@ export function MessageList({
|
|||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
const hasSingleRoundRag = useMemo(
|
const hasSingleRoundRag = useMemo(
|
||||||
() =>
|
() =>
|
||||||
messages.some(
|
messages.some(
|
||||||
(message) =>
|
(message) =>
|
||||||
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
||||||
),
|
),
|
||||||
[messages],
|
[messages],
|
||||||
);
|
);
|
||||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
@@ -436,11 +436,12 @@ export function MessageList({
|
|||||||
className={cn("flex size-full flex-col justify-center", className)}
|
className={cn("flex size-full flex-col justify-center", className)}
|
||||||
>
|
>
|
||||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||||
<ConversationContent
|
<ConversationContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
|
"mx-auto w-full max-w-(--container-width-lg)",
|
||||||
contentClassName,
|
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
|
||||||
)}
|
contentClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||||
{groupMessages(messages, (group) => {
|
{groupMessages(messages, (group) => {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export type MultiRoundRagRetrieval = {
|
|||||||
time?: string;
|
time?: string;
|
||||||
recUuid?: string;
|
recUuid?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
/** Original tool payload, retained so a future renderer can add more fields
|
||||||
|
* without losing data received from the multi-round service. */
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MultiRoundRagRound = {
|
export type MultiRoundRagRound = {
|
||||||
@@ -87,8 +90,11 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
/** Values in the deployed tools are occasionally numeric IDs, not strings. */
|
||||||
return typeof value === "string" ? value : "";
|
function asText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJson(value: unknown): unknown {
|
function parseJson(value: unknown): unknown {
|
||||||
@@ -103,8 +109,11 @@ function parseJson(value: unknown): unknown {
|
|||||||
function parseEventLine(line: string): MultiRoundEvent | null {
|
function parseEventLine(line: string): MultiRoundEvent | null {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed.startsWith("data:")) return null;
|
if (!trimmed.startsWith("data:")) return null;
|
||||||
|
const payload = trimmed.slice("data:".length).trim();
|
||||||
|
if (!payload || payload === "[DONE]") return { event: "done" };
|
||||||
try {
|
try {
|
||||||
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent;
|
const parsed = parseJson(payload);
|
||||||
|
return asRecord(parsed) as MultiRoundEvent | null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -141,10 +150,10 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
|
|||||||
if (current.title === "正在准备检索" && !current.thought && !current.query) {
|
if (current.title === "正在准备检索" && !current.thought && !current.query) {
|
||||||
current.title = "思考过程";
|
current.title = "思考过程";
|
||||||
current.thought = thought;
|
current.thought = thought;
|
||||||
return;
|
return current;
|
||||||
}
|
}
|
||||||
current.isSearching = false;
|
current.isSearching = false;
|
||||||
trace.rounds.push({
|
const next = {
|
||||||
id: createId("multi-rag-round"),
|
id: createId("multi-rag-round"),
|
||||||
title: "思考过程",
|
title: "思考过程",
|
||||||
thought,
|
thought,
|
||||||
@@ -152,7 +161,9 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
|
|||||||
query: "",
|
query: "",
|
||||||
isSearching: true,
|
isSearching: true,
|
||||||
retrievals: [],
|
retrievals: [],
|
||||||
});
|
};
|
||||||
|
trace.rounds.push(next);
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
|
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
|
||||||
@@ -162,49 +173,94 @@ function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
|
|||||||
current.body = `${current.body ?? ""}${body}`;
|
current.body = `${current.body ?? ""}${body}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
|
function firstArray(values: unknown[]): unknown[] {
|
||||||
const root = asRecord(parseJson(value));
|
for (const value of values) {
|
||||||
const list =
|
const parsed = parseJson(value);
|
||||||
(Array.isArray(root?.results) && root.results) ||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
(Array.isArray(root?.organic) && root.organic) ||
|
}
|
||||||
[];
|
return [];
|
||||||
return list.flatMap((item, index) => {
|
}
|
||||||
|
|
||||||
|
function toRecordList(value: unknown): Record<string, unknown>[] {
|
||||||
|
const parsed = parseJson(value);
|
||||||
|
const root = asRecord(parsed);
|
||||||
|
const list = Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
: firstArray([
|
||||||
|
root?.results,
|
||||||
|
root?.organic,
|
||||||
|
root?.data,
|
||||||
|
root?.items,
|
||||||
|
root?.list,
|
||||||
|
root?.documents,
|
||||||
|
]);
|
||||||
|
return list.flatMap((item) => {
|
||||||
|
if (typeof item === "string") return [{ content: item }];
|
||||||
const record = asRecord(item);
|
const record = asRecord(item);
|
||||||
if (!record) return [];
|
return record ? [record] : [];
|
||||||
const recUuid = asString(record.recUuid ?? record.rec_uuid);
|
});
|
||||||
const url = asString(record.url ?? record.link ?? record.href);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeneralQA's service fans out to several business search tools. Normalising
|
||||||
|
* all of their result shapes here prevents a successful tool call from being
|
||||||
|
* silently absent just because it did not use `results` or `organic`.
|
||||||
|
*/
|
||||||
|
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
|
||||||
|
const list = toRecordList(value);
|
||||||
|
return list.flatMap((item, index) => {
|
||||||
|
const recUuid = asText(item.recUuid ?? item.rec_uuid ?? item.uuid);
|
||||||
|
const url = asText(item.url ?? item.link ?? item.href);
|
||||||
|
const title = asText(
|
||||||
|
item.title ?? item.m_title ?? item.taskName ?? item.user_name ?? item.name ?? item.user_id,
|
||||||
|
);
|
||||||
|
const content = asText(
|
||||||
|
item.content ??
|
||||||
|
item.m_content ??
|
||||||
|
item.m_conent ??
|
||||||
|
item.snippet ??
|
||||||
|
item.text ??
|
||||||
|
item.overview ??
|
||||||
|
item.page_content ??
|
||||||
|
item.description ??
|
||||||
|
item.summary,
|
||||||
|
);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: asString(record.id) || recUuid || undefined,
|
id: asText(item.id) || recUuid || undefined,
|
||||||
title: asString(record.title) || `检索资料 ${index + 1}`,
|
title: title || `检索资料 ${index + 1}`,
|
||||||
content: asString(record.content ?? record.snippet ?? record.text),
|
content: content || (url && !title ? url : ""),
|
||||||
source: asString(record.source ?? record.source1 ?? url) || undefined,
|
source: asText(item.source ?? item.source1 ?? item.m_source ?? item.platform) || undefined,
|
||||||
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined,
|
time: asText(item.publish_time ?? item.m_publish ?? item.m_publicsh ?? item.date ?? item.time) || undefined,
|
||||||
recUuid: recUuid || undefined,
|
recUuid: recUuid || undefined,
|
||||||
url: /^https?:\/\//i.test(url) ? url : undefined,
|
url: /^https?:\/\//i.test(url) ? url : undefined,
|
||||||
|
raw: item,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapReferences(value: unknown): MultiRoundRagSource[] {
|
function mapReferences(value: unknown): MultiRoundRagSource[] {
|
||||||
const record = asRecord(value);
|
const parsed = parseJson(value);
|
||||||
const list = Array.isArray(record?.references) ? record.references : [];
|
const record = asRecord(parsed);
|
||||||
|
const list = Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
: firstArray([record?.references, record?.results, record?.sources, record?.documents]);
|
||||||
return list.flatMap((item, index) => {
|
return list.flatMap((item, index) => {
|
||||||
const source = asRecord(item);
|
const source = asRecord(item);
|
||||||
if (!source) return [];
|
if (!source) return [];
|
||||||
const content = asString(source.content ?? source.snippet);
|
const content = asText(source.content ?? source.m_content ?? source.m_conent ?? source.snippet ?? source.text);
|
||||||
const rawUrl = asString(source.url ?? source.link ?? source.href);
|
const rawUrl = asText(source.url ?? source.link ?? source.href);
|
||||||
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid);
|
const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: asString(source.id) || recUuid || undefined,
|
id: asText(source.id) || recUuid || undefined,
|
||||||
title: asString(source.title) || `参考资料 ${index + 1}`,
|
title: asText(source.title ?? source.m_title ?? source.taskName ?? source.user_name) || `参考资料 ${index + 1}`,
|
||||||
type: asString(source.type) || "参考资料",
|
type: asText(source.type) || "参考资料",
|
||||||
snippet: asString(source.snippet) || content,
|
snippet: asText(source.snippet) || content,
|
||||||
content,
|
content,
|
||||||
source: asString(source.source ?? source.source1) || undefined,
|
source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined,
|
||||||
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined,
|
time: asText(source.time ?? source.m_publish ?? source.m_publicsh ?? source.publish_time ?? source.date) || undefined,
|
||||||
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
|
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
|
||||||
raw: source,
|
raw: source,
|
||||||
},
|
},
|
||||||
@@ -222,6 +278,71 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolTitle(toolName: string): string {
|
||||||
|
switch (toolName) {
|
||||||
|
case "local_search":
|
||||||
|
return "本地知识检索";
|
||||||
|
case "tavily_search":
|
||||||
|
return "联网检索";
|
||||||
|
case "google_search":
|
||||||
|
case "getTaskLikeTop":
|
||||||
|
return "思考过程";
|
||||||
|
case "search_user_profile":
|
||||||
|
return "靶标平台画像库";
|
||||||
|
case "get_taiwan_news":
|
||||||
|
case "search_keyword_taiwan_news":
|
||||||
|
return "台湾热点新闻";
|
||||||
|
case "get_world_news":
|
||||||
|
return "世界热点新闻";
|
||||||
|
case "get_root_messaage_comment":
|
||||||
|
return "获取该消息的全部评论";
|
||||||
|
case "search_user_behavior":
|
||||||
|
return "查询该用户的历史行为";
|
||||||
|
case "get_america_news":
|
||||||
|
return "美国热点新闻";
|
||||||
|
case "get_time_data":
|
||||||
|
return "态势新闻搜索";
|
||||||
|
case "get_textid_content":
|
||||||
|
return "获取新闻详细内容";
|
||||||
|
default:
|
||||||
|
return toolName || "检索过程";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSearchTool(toolName: string): boolean {
|
||||||
|
return [
|
||||||
|
"local_search",
|
||||||
|
"tavily_search",
|
||||||
|
"google_search",
|
||||||
|
"getTaskLikeTop",
|
||||||
|
"search_user_profile",
|
||||||
|
"get_taiwan_news",
|
||||||
|
"search_keyword_taiwan_news",
|
||||||
|
"get_world_news",
|
||||||
|
"get_root_messaage_comment",
|
||||||
|
"search_user_behavior",
|
||||||
|
"get_america_news",
|
||||||
|
"get_time_data",
|
||||||
|
].includes(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryFromToolInput(input: Record<string, unknown>, result: unknown): string {
|
||||||
|
const resultRecord = asRecord(parseJson(result));
|
||||||
|
const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig);
|
||||||
|
if (direct) return direct;
|
||||||
|
if (input.s || input.e) {
|
||||||
|
return [asText(input.s), asText(input.e)].filter(Boolean).join(" - ");
|
||||||
|
}
|
||||||
|
return asText(resultRecord?.query ?? resultRecord?.q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromEventData(value: unknown): string {
|
||||||
|
const parsed = parseJson(value);
|
||||||
|
if (typeof parsed === "string") return parsed;
|
||||||
|
const record = asRecord(parsed);
|
||||||
|
return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output);
|
||||||
|
}
|
||||||
|
|
||||||
function isSingleRoundRagMessage(message: Message) {
|
function isSingleRoundRagMessage(message: Message) {
|
||||||
return message.additional_kwargs?.single_round_rag === true;
|
return message.additional_kwargs?.single_round_rag === true;
|
||||||
}
|
}
|
||||||
@@ -370,92 +491,204 @@ export function useMultiRoundRagMessages(threadId: string) {
|
|||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
|
let activeThoughtRound: MultiRoundRagRound | null = null;
|
||||||
|
let activeThoughtTailIsAnswer = false;
|
||||||
|
let answerFromShowText = false;
|
||||||
|
|
||||||
|
const appendAnswer = (text: string, allowCompleteSnapshot = false) => {
|
||||||
|
if (!text) return;
|
||||||
|
// Some gateways emit one complete `message` after a `show_text`
|
||||||
|
// answer. Restrict snapshot detection to that case and to a
|
||||||
|
// meaningful length: adjacent streaming chunks can legitimately
|
||||||
|
// contain the same character, and must never be discarded.
|
||||||
|
if (
|
||||||
|
allowCompleteSnapshot &&
|
||||||
|
trace.answer.length >= 16 &&
|
||||||
|
text.startsWith(trace.answer)
|
||||||
|
) {
|
||||||
|
trace.answer = text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trace.answer += text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const consumeShowText = (text: string, tailIsAnswer = false) => {
|
||||||
|
if (!text) return;
|
||||||
|
if (activeThoughtRound) {
|
||||||
|
const endIndex = text.indexOf("</think>");
|
||||||
|
if (endIndex < 0) {
|
||||||
|
activeThoughtRound.thought += text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeThoughtRound.thought += text.slice(0, endIndex);
|
||||||
|
const roundBody = text.slice(endIndex + "</think>".length).trim();
|
||||||
|
if (roundBody) {
|
||||||
|
if (activeThoughtTailIsAnswer) {
|
||||||
|
appendAnswer(roundBody);
|
||||||
|
answerFromShowText = false;
|
||||||
|
trace.isComplete = true;
|
||||||
|
} else {
|
||||||
|
appendRoundBody(trace, roundBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeThoughtRound = null;
|
||||||
|
activeThoughtTailIsAnswer = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thinkStart = text.indexOf("<think>");
|
||||||
|
if (thinkStart < 0) {
|
||||||
|
const current = latestRound(trace);
|
||||||
|
current.isSearching = false;
|
||||||
|
appendAnswer(text);
|
||||||
|
answerFromShowText = true;
|
||||||
|
trace.isComplete = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = text.slice(0, thinkStart).trim();
|
||||||
|
if (prefix) appendRoundBody(trace, prefix);
|
||||||
|
|
||||||
|
// A full <think>…</think> envelope is normally delivered as one
|
||||||
|
// tool call. Keep the unfinished variant open as well: production
|
||||||
|
// proxies are allowed to split an SSE frame at any byte boundary.
|
||||||
|
const thinkEnd = text.indexOf("</think>", thinkStart + "<think>".length);
|
||||||
|
const thought = text
|
||||||
|
.slice(thinkStart + "<think>".length, thinkEnd < 0 ? undefined : thinkEnd)
|
||||||
|
.trim();
|
||||||
|
const thoughtRound = addThinkingRound(trace, thought);
|
||||||
|
if (thinkEnd < 0) {
|
||||||
|
activeThoughtRound = thoughtRound;
|
||||||
|
activeThoughtTailIsAnswer = tailIsAnswer;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roundBody = text.slice(thinkEnd + "</think>".length).trim();
|
||||||
|
if (roundBody) {
|
||||||
|
if (tailIsAnswer) {
|
||||||
|
appendAnswer(roundBody);
|
||||||
|
answerFromShowText = false;
|
||||||
|
trace.isComplete = true;
|
||||||
|
} else {
|
||||||
|
appendRoundBody(trace, roundBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const consumeToolCall = (payload: unknown) => {
|
||||||
|
const parsedPayload = parseJson(payload);
|
||||||
|
const tools = Array.isArray(parsedPayload) ? parsedPayload : [parsedPayload];
|
||||||
|
for (const toolValue of tools) {
|
||||||
|
const tool = asRecord(toolValue);
|
||||||
|
if (!tool) continue;
|
||||||
|
const toolName = asText(tool.tool_name ?? tool.name);
|
||||||
|
const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {};
|
||||||
|
const current = latestRound(trace);
|
||||||
|
|
||||||
|
if (toolName === "show_text") {
|
||||||
|
consumeShowText(asText(input.text ?? input.content));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === "get_textid_content") {
|
||||||
|
current.title = toolTitle(toolName);
|
||||||
|
const article = asRecord(parseJson(input.result));
|
||||||
|
const body = asText(article?.content ?? article?.text ?? input.content ?? input.result);
|
||||||
|
if (body) appendRoundBody(trace, body);
|
||||||
|
current.isSearching = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasResult = input.result !== undefined;
|
||||||
|
const query = queryFromToolInput(input, input.result);
|
||||||
|
if (isSearchTool(toolName) || hasResult) {
|
||||||
|
current.title = toolTitle(toolName);
|
||||||
|
current.query = query || current.query;
|
||||||
|
if (!hasResult && isSearchTool(toolName)) {
|
||||||
|
// GeneralQA can receive a model text event before the search
|
||||||
|
// tool announces its query. The query starts a new visible
|
||||||
|
// loading phase, so restore the round/trace state here and
|
||||||
|
// keep the query badge spinning until its result arrives.
|
||||||
|
current.isSearching = true;
|
||||||
|
trace.isComplete = false;
|
||||||
|
} else if (hasResult) {
|
||||||
|
const retrievals = mapRetrievals(input.result);
|
||||||
|
// An empty search response is still a completed response; it
|
||||||
|
// must replace a previous list instead of leaving stale cards.
|
||||||
|
current.retrievals = retrievals;
|
||||||
|
if (retrievals.length === 0) {
|
||||||
|
const fallbackBody = textFromEventData(input.result);
|
||||||
|
if (fallbackBody && fallbackBody !== current.query) {
|
||||||
|
appendRoundBody(trace, fallbackBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.isSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishTrace = () => {
|
||||||
|
trace.isComplete = true;
|
||||||
|
activeThoughtRound = null;
|
||||||
|
activeThoughtTailIsAnswer = false;
|
||||||
|
for (const round of trace.rounds) round.isSearching = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const consumeEvent = (event: MultiRoundEvent) => {
|
||||||
|
const eventType = asText(event.event);
|
||||||
|
const data = event.data;
|
||||||
|
if (eventType === "tool_call") {
|
||||||
|
consumeToolCall(data);
|
||||||
|
} else if (eventType === "message") {
|
||||||
|
const text = textFromEventData(data);
|
||||||
|
if (text) {
|
||||||
|
if (activeThoughtRound || /<\/?think>/i.test(text)) {
|
||||||
|
// Most deployments put thoughts in `show_text`, but this
|
||||||
|
// fallback also preserves a compatible gateway that emits
|
||||||
|
// `<think>` in normal message chunks.
|
||||||
|
consumeShowText(text, true);
|
||||||
|
} else {
|
||||||
|
latestRound(trace).isSearching = false;
|
||||||
|
appendAnswer(text, answerFromShowText);
|
||||||
|
answerFromShowText = false;
|
||||||
|
trace.isComplete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (eventType === "references") {
|
||||||
|
sources = mapReferences(data);
|
||||||
|
} else if (eventType === "error") {
|
||||||
|
const message = textFromEventData(data) || "多轮 RAG 服务返回错误。";
|
||||||
|
setError(message);
|
||||||
|
if (!trace.answer) trace.answer = message;
|
||||||
|
finishTrace();
|
||||||
|
} else if (eventType === "done") {
|
||||||
|
// A few compatible gateway versions include the whole final
|
||||||
|
// answer in `done`; render it when no `message` chunk was sent.
|
||||||
|
const finalText = textFromEventData(data);
|
||||||
|
if (finalText && !trace.answer) appendAnswer(finalText);
|
||||||
|
finishTrace();
|
||||||
|
}
|
||||||
|
refreshAssistant();
|
||||||
|
};
|
||||||
|
|
||||||
|
const consumeLines = (flush = false) => {
|
||||||
|
const lines = buffer.split(/\r?\n/);
|
||||||
|
buffer = flush ? "" : (lines.pop() ?? "");
|
||||||
|
for (const line of lines) {
|
||||||
|
const event = parseEventLine(line);
|
||||||
|
if (event) consumeEvent(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const lines = buffer.split(/\r?\n/);
|
consumeLines();
|
||||||
buffer = lines.pop() ?? "";
|
|
||||||
for (const line of lines) {
|
|
||||||
const event = parseEventLine(line);
|
|
||||||
if (!event) continue;
|
|
||||||
const eventType = asString(event.event);
|
|
||||||
const data = event.data;
|
|
||||||
if (eventType === "tool_call") {
|
|
||||||
const tool = asRecord(data);
|
|
||||||
const toolName = asString(tool?.tool_name);
|
|
||||||
const input = asRecord(tool?.tool_input) ?? {};
|
|
||||||
const current = latestRound(trace);
|
|
||||||
if (toolName === "show_text") {
|
|
||||||
const text = asString(input.text);
|
|
||||||
if (text.includes("<think>")) {
|
|
||||||
const thinkEnd = text.indexOf("</think>");
|
|
||||||
addThinkingRound(
|
|
||||||
trace,
|
|
||||||
(thinkEnd >= 0
|
|
||||||
? text.slice(0, thinkEnd + "</think>".length)
|
|
||||||
: text
|
|
||||||
)
|
|
||||||
.replace(/<\/?think>/g, "")
|
|
||||||
.trim(),
|
|
||||||
);
|
|
||||||
if (thinkEnd >= 0) {
|
|
||||||
const roundBody = text
|
|
||||||
.slice(thinkEnd + "</think>".length)
|
|
||||||
.trim();
|
|
||||||
if (roundBody) {
|
|
||||||
appendRoundBody(trace, roundBody);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// A non-thinking `show_text` immediately after a thought is
|
|
||||||
// this round's narrative; the terminal `message` event is
|
|
||||||
// still rendered only as the final answer below all rounds.
|
|
||||||
current.isSearching = false;
|
|
||||||
trace.answer += text;
|
|
||||||
trace.isComplete = true;
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
toolName === "local_search" ||
|
|
||||||
toolName === "tavily_search" ||
|
|
||||||
toolName === "google_search"
|
|
||||||
) {
|
|
||||||
const queryData = asRecord(parseJson(input.result));
|
|
||||||
current.title =
|
|
||||||
toolName === "local_search" ? "本地知识检索" : "联网检索";
|
|
||||||
current.query =
|
|
||||||
asString(input.q ?? input.query) ||
|
|
||||||
asString(queryData?.query) ||
|
|
||||||
current.query;
|
|
||||||
const retrievals = mapRetrievals(input.result);
|
|
||||||
if (retrievals.length > 0) current.retrievals = retrievals;
|
|
||||||
current.isSearching = false;
|
|
||||||
} else if (toolName === "get_textid_content") {
|
|
||||||
const article = asRecord(parseJson(input.result));
|
|
||||||
const body =
|
|
||||||
asString(article?.content ?? article?.text) ||
|
|
||||||
asString(input.content);
|
|
||||||
if (body) appendRoundBody(trace, body);
|
|
||||||
current.isSearching = false;
|
|
||||||
}
|
|
||||||
refreshAssistant();
|
|
||||||
} else if (eventType === "message" && typeof data === "string") {
|
|
||||||
latestRound(trace).isSearching = false;
|
|
||||||
trace.answer += data;
|
|
||||||
trace.isComplete = true;
|
|
||||||
refreshAssistant();
|
|
||||||
} else if (eventType === "references") {
|
|
||||||
sources = mapReferences(data);
|
|
||||||
refreshAssistant();
|
|
||||||
} else if (eventType === "done") {
|
|
||||||
trace.isComplete = true;
|
|
||||||
for (const round of trace.rounds) round.isSearching = false;
|
|
||||||
refreshAssistant();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
trace.isComplete = true;
|
buffer += decoder.decode();
|
||||||
for (const round of trace.rounds) round.isSearching = false;
|
consumeLines(true);
|
||||||
|
finishTrace();
|
||||||
refreshAssistant();
|
refreshAssistant();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
trace.isComplete = true;
|
trace.isComplete = true;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Package frontend node_modules for offline development.
|
||||||
|
# Uses tar -h to dereference pnpm junctions so the archive is self-contained.
|
||||||
|
param(
|
||||||
|
[string]$OutputDir = (Join-Path (Split-Path $PSScriptRoot -Parent) "dist")
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path $PSScriptRoot -Parent
|
||||||
|
$Frontend = Join-Path $Root "frontend-web"
|
||||||
|
$Archive = Join-Path $OutputDir "frontend-offline-deps.tar.gz"
|
||||||
|
|
||||||
|
if (-not (Test-Path (Join-Path $Frontend "node_modules\.pnpm"))) {
|
||||||
|
throw "frontend-web/node_modules is missing or incomplete. Run 'pnpm install' in frontend-web first."
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||||
|
if (Test-Path $Archive) { Remove-Item $Archive -Force }
|
||||||
|
|
||||||
|
Write-Host "Packaging frontend dependencies..."
|
||||||
|
Write-Host " Source: $Frontend"
|
||||||
|
Write-Host " Output: $Archive"
|
||||||
|
|
||||||
|
$start = Get-Date
|
||||||
|
tar -czhf $Archive -C $Frontend node_modules package.json pnpm-lock.yaml
|
||||||
|
$elapsed = ((Get-Date) - $start).TotalSeconds
|
||||||
|
$sizeMb = [math]::Round((Get-Item $Archive).Length / 1MB, 2)
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Done in ${elapsed}s — ${sizeMb} MB"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Copy to offline machine, then run:"
|
||||||
|
Write-Host " scripts\restore-frontend-offline-deps.ps1 -Archive <path-to-archive>"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
FRONTEND="${ROOT}/frontend-web"
|
||||||
|
OUTPUT_DIR="${1:-${ROOT}/dist}"
|
||||||
|
ARCHIVE="${OUTPUT_DIR}/frontend-offline-deps.tar.gz"
|
||||||
|
|
||||||
|
if [[ ! -d "${FRONTEND}/node_modules/.pnpm" ]]; then
|
||||||
|
echo "frontend-web/node_modules is missing or incomplete. Run 'pnpm install' in frontend-web first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${OUTPUT_DIR}"
|
||||||
|
rm -f "${ARCHIVE}"
|
||||||
|
|
||||||
|
echo "Packaging frontend dependencies..."
|
||||||
|
echo " Source: ${FRONTEND}"
|
||||||
|
echo " Output: ${ARCHIVE}"
|
||||||
|
|
||||||
|
tar -czhf "${ARCHIVE}" -C "${FRONTEND}" node_modules package.json pnpm-lock.yaml
|
||||||
|
|
||||||
|
SIZE_MB=$(du -m "${ARCHIVE}" | awk '{print $1}')
|
||||||
|
echo ""
|
||||||
|
echo "Done — ${SIZE_MB} MB"
|
||||||
|
echo ""
|
||||||
|
echo "Copy to offline machine, then run:"
|
||||||
|
echo " scripts/restore-frontend-offline-deps.sh <path-to-archive>"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Restore frontend node_modules from an offline dependency archive.
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Archive
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path $PSScriptRoot -Parent
|
||||||
|
$Frontend = Join-Path $Root "frontend-web"
|
||||||
|
|
||||||
|
if (-not (Test-Path $Archive)) {
|
||||||
|
throw "Archive not found: $Archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Restoring frontend dependencies to $Frontend ..."
|
||||||
|
|
||||||
|
# Back up existing node_modules if present
|
||||||
|
$backup = Join-Path $Frontend "node_modules.bak"
|
||||||
|
if (Test-Path (Join-Path $Frontend "node_modules")) {
|
||||||
|
if (Test-Path $backup) { Remove-Item $backup -Recurse -Force }
|
||||||
|
Rename-Item (Join-Path $Frontend "node_modules") "node_modules.bak"
|
||||||
|
Write-Host " Existing node_modules backed up to node_modules.bak"
|
||||||
|
}
|
||||||
|
|
||||||
|
tar -xzf $Archive -C $Frontend
|
||||||
|
|
||||||
|
# Quick sanity check
|
||||||
|
$required = @(
|
||||||
|
"node_modules\.pnpm",
|
||||||
|
"node_modules\react\package.json",
|
||||||
|
"node_modules\vite\package.json"
|
||||||
|
)
|
||||||
|
foreach ($rel in $required) {
|
||||||
|
if (-not (Test-Path (Join-Path $Frontend $rel))) {
|
||||||
|
throw "Restore incomplete — missing $rel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Restore complete. You can now run:"
|
||||||
|
Write-Host " cd frontend-web"
|
||||||
|
Write-Host " pnpm dev"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
echo "Usage: $0 <path-to-frontend-offline-deps.tar.gz>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCHIVE="$1"
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
FRONTEND="${ROOT}/frontend-web"
|
||||||
|
|
||||||
|
if [[ ! -f "${ARCHIVE}" ]]; then
|
||||||
|
echo "Archive not found: ${ARCHIVE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Restoring frontend dependencies to ${FRONTEND} ..."
|
||||||
|
|
||||||
|
if [[ -d "${FRONTEND}/node_modules" ]]; then
|
||||||
|
rm -rf "${FRONTEND}/node_modules.bak"
|
||||||
|
mv "${FRONTEND}/node_modules" "${FRONTEND}/node_modules.bak"
|
||||||
|
echo " Existing node_modules backed up to node_modules.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -xzf "${ARCHIVE}" -C "${FRONTEND}"
|
||||||
|
|
||||||
|
for rel in node_modules/.pnpm node_modules/react/package.json node_modules/vite/package.json; do
|
||||||
|
if [[ ! -e "${FRONTEND}/${rel}" ]]; then
|
||||||
|
echo "Restore incomplete — missing ${rel}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Restore complete. You can now run:"
|
||||||
|
echo " cd frontend-web"
|
||||||
|
echo " pnpm dev"
|
||||||
Reference in new issue
Block a user