Merge remote-tracking branch 'origin/master' into ji
This commit is contained in:
commit
b6a2c9336e
38 files changed
+3616
-140
No files matched your search
@@ -19,8 +19,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAdminAddMemoryEntry,
|
||||
useAdminAddV1Fact,
|
||||
useAdminDeleteMemoryEntry,
|
||||
useAdminDeleteV1Fact,
|
||||
useAdminReplaceMemoryEntry,
|
||||
useAdminUpdateV1Fact,
|
||||
useAdminUpdateV1Section,
|
||||
useUserMemory,
|
||||
} from "@/core/admin/hooks";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
@@ -31,6 +35,288 @@ function getEntries(data: Record<string, unknown>): string[] {
|
||||
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
|
||||
}
|
||||
|
||||
// ── V1 只读视图 ────────────────────────────────────────────────────────────
|
||||
|
||||
const CTX_LABELS: Record<string, string> = {
|
||||
workContext: "工作背景",
|
||||
personalContext: "个人信息",
|
||||
topOfMind: "当前重点",
|
||||
};
|
||||
const HISTORY_LABELS: Record<string, string> = {
|
||||
recentMonths: "近期",
|
||||
earlierContext: "早期背景",
|
||||
longTermBackground: "长期背景",
|
||||
};
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
preference: "bg-blue-100 text-blue-700",
|
||||
knowledge: "bg-green-100 text-green-700",
|
||||
context: "bg-yellow-100 text-yellow-700",
|
||||
behavior: "bg-purple-100 text-purple-700",
|
||||
goal: "bg-orange-100 text-orange-700",
|
||||
correction: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
const V1_CATEGORIES = ["preference", "knowledge", "context", "behavior", "goal", "correction"];
|
||||
|
||||
function extractSummary(val: unknown): string {
|
||||
if (typeof val === "string") return val;
|
||||
if (val && typeof val === "object" && "summary" in val)
|
||||
return String((val as Record<string, unknown>).summary ?? "");
|
||||
return "";
|
||||
}
|
||||
|
||||
// 单个摘要字段的行内编辑
|
||||
function V1SectionField({
|
||||
label,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onSave: (v: string) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
if (draft.trim() === value.trim()) { setEditing(false); return; }
|
||||
setSaving(true);
|
||||
try { await onSave(draft.trim()); setEditing(false); }
|
||||
catch (err) { toast.error(err instanceof Error ? err.message : "保存失败"); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{!editing && (
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="space-y-1.5">
|
||||
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[72px] text-sm" autoFocus />
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap text-foreground/80 min-h-[1.5rem]">{value || <span className="text-muted-foreground italic">(空)</span>}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 单条事实的行内编辑
|
||||
function V1FactItem({
|
||||
fact,
|
||||
agent,
|
||||
userId,
|
||||
}: {
|
||||
fact: Record<string, unknown>;
|
||||
agent: string | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftContent, setDraftContent] = useState(String(fact.content ?? ""));
|
||||
const [draftCategory, setDraftCategory] = useState(String(fact.category ?? "context"));
|
||||
const [draftConfidence, setDraftConfidence] = useState(
|
||||
typeof fact.confidence === "number" ? Math.round(fact.confidence * 100) : 80,
|
||||
);
|
||||
const updateFact = useAdminUpdateV1Fact(userId);
|
||||
const deleteFact = useAdminDeleteV1Fact(userId);
|
||||
|
||||
const factId = String(fact.id ?? "");
|
||||
const confidence = typeof fact.confidence === "number" ? fact.confidence : 0;
|
||||
const category = String(fact.category ?? "context");
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await updateFact.mutateAsync({
|
||||
factId,
|
||||
updates: { content: draftContent.trim(), category: draftCategory, confidence: draftConfidence / 100 },
|
||||
agent,
|
||||
});
|
||||
setEditing(false);
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : "更新失败"); }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try { await deleteFact.mutateAsync({ factId, agent }); }
|
||||
catch (err) { toast.error(err instanceof Error ? err.message : "删除失败"); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border p-2 space-y-1 group">
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={draftCategory} onValueChange={setDraftCategory}>
|
||||
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">置信度</span>
|
||||
<input
|
||||
type="number" min={0} max={100} value={draftConfidence}
|
||||
onChange={(e) => setDraftConfidence(Number(e.target.value))}
|
||||
className="w-16 h-7 rounded border px-2 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={handleSave} disabled={updateFact.isPending}>
|
||||
{updateFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category] ?? "bg-gray-100 text-gray-700"}`}>{category}</span>
|
||||
<span className="text-xs text-muted-foreground">{Math.round(confidence * 100)}%</span>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraftContent(String(fact.content ?? "")); setDraftCategory(category); setDraftConfidence(Math.round(confidence * 100)); setEditing(true); }}>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 hover:text-destructive" onClick={handleDelete} disabled={deleteFact.isPending}>
|
||||
{deleteFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{String(fact.content ?? "")}</p>
|
||||
{fact.sourceError ? <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">避免:{String(fact.sourceError)}</p> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 新增事实表单
|
||||
function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const [category, setCategory] = useState("context");
|
||||
const [confidence, setConfidence] = useState(80);
|
||||
const addFact = useAdminAddV1Fact(userId);
|
||||
|
||||
async function handleAdd() {
|
||||
if (!content.trim()) return;
|
||||
try {
|
||||
await addFact.mutateAsync({ content: content.trim(), category, confidence: confidence / 100, agent });
|
||||
setContent(""); setCategory("context"); setConfidence(80); setOpen(false);
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : "新增失败"); }
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="mt-1 w-full" onClick={() => setOpen(true)}>
|
||||
<Plus className="size-3" />新增事实
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Textarea placeholder="事实内容…" value={content} onChange={(e) => setContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">置信度</span>
|
||||
<input type="number" min={0} max={100} value={confidence} onChange={(e) => setConfidence(Number(e.target.value))} className="w-16 h-7 rounded border px-2 text-xs" />
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={handleAdd} disabled={addFact.isPending || !content.trim()}>
|
||||
{addFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}确认
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}><X className="size-3" />取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function V1BucketView({
|
||||
data,
|
||||
agent,
|
||||
userId,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
agent: string | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const updateSection = useAdminUpdateV1Section(userId);
|
||||
|
||||
const ctxRaw = (data.user ?? data.userContext ?? {}) as Record<string, unknown>;
|
||||
const historyRaw = (data.history ?? {}) as Record<string, unknown>;
|
||||
const factsRaw = Array.isArray(data.facts) ? (data.facts as Record<string, unknown>[]) : [];
|
||||
|
||||
const allCtxFields = ["workContext", "personalContext", "topOfMind"];
|
||||
const allHistoryFields = ["recentMonths", "earlierContext", "longTermBackground"];
|
||||
|
||||
if (!Object.keys(ctxRaw).length && !Object.keys(historyRaw).length && !factsRaw.length) {
|
||||
return <p className="text-muted-foreground py-6 text-center text-sm">暂无 V1 记忆</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 用户画像字段 */}
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">用户画像</h4>
|
||||
{allCtxFields.map((field) => (
|
||||
<V1SectionField
|
||||
key={field}
|
||||
label={CTX_LABELS[field] ?? field}
|
||||
value={extractSummary(ctxRaw[field])}
|
||||
onSave={(summary) =>
|
||||
updateSection.mutateAsync({ sectionType: "user", field, summary, agent }).then(() => undefined)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 历史摘要字段 */}
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">历史摘要</h4>
|
||||
{allHistoryFields.map((field) => (
|
||||
<V1SectionField
|
||||
key={field}
|
||||
label={HISTORY_LABELS[field] ?? field}
|
||||
value={extractSummary(historyRaw[field])}
|
||||
onSave={(summary) =>
|
||||
updateSection.mutateAsync({ sectionType: "history", field, summary, agent }).then(() => undefined)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 事实记录 */}
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">事实记录({factsRaw.length} 条)</h4>
|
||||
{factsRaw.map((fact, i) => (
|
||||
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} />
|
||||
))}
|
||||
<V1AddFactForm agent={agent} userId={userId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Memory entry list with inline editing ──────────────────────────────────
|
||||
|
||||
function MemoryEntryList({
|
||||
@@ -217,11 +503,76 @@ function UserMemoryPanel({ userId }: { userId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const isV1 = memory?.version === "v1";
|
||||
const globalBucket = memory?.buckets.find((b) => b.name === "global");
|
||||
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
|
||||
|
||||
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
|
||||
// ── V1 只读视图 ──────────────────────────────────────────────────────────
|
||||
if (isV1) {
|
||||
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||||
const currentBucket = currentAgent
|
||||
? agentBuckets.find((b) => b.name === currentAgent)
|
||||
: globalBucket;
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="global" className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b px-4 pt-4">
|
||||
<div className="mb-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-1.5 text-xs text-amber-700">
|
||||
V1 模式:记忆由系统自动提取,不支持手动管理
|
||||
</div>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="global">用户记忆</TabsTrigger>
|
||||
{agentBuckets.length > 0 && (
|
||||
<TabsTrigger value="agent">
|
||||
Agent 记忆
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{agentBuckets.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="global" className="flex-1 overflow-hidden p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{globalBucket ? <V1BucketView data={globalBucket.data} agent={null} userId={userId} /> : (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">暂无数据</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{agentBuckets.length > 0 && (
|
||||
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">选择 Agent:</span>
|
||||
<Select value={currentAgent ?? ""} onValueChange={(v) => setSelectedAgent(v)}>
|
||||
<SelectTrigger className="h-7 w-48 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentBuckets.map((b) => (
|
||||
<SelectItem key={b.name} value={b.name} className="text-xs">
|
||||
{b.display_name || b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{currentBucket && <V1BucketView data={currentBucket.data} agent={currentAgent} userId={userId} />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// ── V2 可编辑视图 ────────────────────────────────────────────────────────
|
||||
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
|
||||
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||||
const agentBucket = agentBuckets.find((b) => b.name === currentAgent);
|
||||
const agentEntries = agentBucket ? getEntries(agentBucket.data) : [];
|
||||
@@ -284,16 +635,10 @@ function UserMemoryPanel({ userId }: { userId: string }) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
— MEMORY.md
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">— MEMORY.md</span>
|
||||
</div>
|
||||
{currentAgent && (
|
||||
<MemoryEntryList
|
||||
entries={agentEntries}
|
||||
userId={userId}
|
||||
agent={currentAgent}
|
||||
/>
|
||||
<MemoryEntryList entries={agentEntries} userId={userId} agent={currentAgent} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
@@ -134,6 +135,7 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -147,6 +149,7 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
@@ -285,6 +288,14 @@ export function InputBox({
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
memory_injection_enabled: !memoryInjectionEnabled,
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
@@ -590,6 +601,26 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
: "记忆注入已关闭:本次对话不注入记忆"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
memoryInjectionEnabled
|
||||
? "border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleMemoryInjectionToggle}
|
||||
>
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
normalizeSearchResults,
|
||||
resolveSearchQuery,
|
||||
type SearchResultItem,
|
||||
} from "@/core/messages/search-results";
|
||||
import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
@@ -216,15 +221,21 @@ function ToolCall({
|
||||
const { t } = useI18n();
|
||||
const artifacts = useArtifactsOptional();
|
||||
|
||||
if (name === "web_search") {
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
if (typeof args.query === "string") {
|
||||
label = t.toolCalls.searchOnWebFor(args.query);
|
||||
if (query) {
|
||||
label =
|
||||
name === "web_search"
|
||||
? t.toolCalls.searchOnWebFor(query)
|
||||
: t.toolCalls.searchFor(query);
|
||||
}
|
||||
const items = normalizeSearchResults(result);
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||
{items.length > 0 && <WebSearchResultsList results={items} />}
|
||||
{searchItems.length > 0 && (
|
||||
<WebSearchResultsList results={searchItems} />
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "image_search") {
|
||||
@@ -501,32 +512,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
return steps;
|
||||
}
|
||||
|
||||
type WebSearchItem = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
content?: string;
|
||||
published_date?: string;
|
||||
publishedDate?: string;
|
||||
date?: string;
|
||||
};
|
||||
|
||||
function normalizeSearchResults(
|
||||
result: string | Record<string, unknown> | undefined,
|
||||
): WebSearchItem[] {
|
||||
if (Array.isArray(result)) {
|
||||
return result as WebSearchItem[];
|
||||
}
|
||||
if (result && typeof result === "object") {
|
||||
const nested = (result as { results?: unknown }).results;
|
||||
if (Array.isArray(nested)) {
|
||||
return nested as WebSearchItem[];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function WebSearchResultsList({ results }: { results: WebSearchItem[] }) {
|
||||
function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||||
@@ -548,16 +534,16 @@ function WebSearchResultsList({ results }: { results: WebSearchItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: WebSearchItem }) {
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||
let source: string | undefined;
|
||||
let sourceLabel: string | undefined = item.source;
|
||||
if (item.url) {
|
||||
try {
|
||||
source = new URL(item.url).hostname.replace(/^www\./, "");
|
||||
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
source = undefined;
|
||||
sourceLabel = sourceLabel ?? item.url;
|
||||
}
|
||||
}
|
||||
return (
|
||||
@@ -572,10 +558,10 @@ function SearchResultCard({ item }: { item: WebSearchItem }) {
|
||||
{item.title}
|
||||
</div>
|
||||
)}
|
||||
{(source || date) && (
|
||||
{(sourceLabel || date) && (
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
{source && <span className="truncate">{source}</span>}
|
||||
{source && date && <span>·</span>}
|
||||
{sourceLabel && <span className="truncate">{sourceLabel}</span>}
|
||||
{sourceLabel && date && <span>·</span>}
|
||||
{date && <span>{date}</span>}
|
||||
</div>
|
||||
)}
|
||||
@@ -591,10 +577,10 @@ function SearchResultCard({ item }: { item: WebSearchItem }) {
|
||||
<DialogTitle className="pr-6 text-base leading-relaxed">
|
||||
{item.title ?? "检索结果"}
|
||||
</DialogTitle>
|
||||
{(source || date) && (
|
||||
{(sourceLabel || date) && (
|
||||
<DialogDescription className="flex items-center gap-2 text-xs">
|
||||
{source && <span>{source}</span>}
|
||||
{source && date && <span>·</span>}
|
||||
{sourceLabel && <span>{sourceLabel}</span>}
|
||||
{sourceLabel && date && <span>·</span>}
|
||||
{date && <span>{date}</span>}
|
||||
</DialogDescription>
|
||||
)}
|
||||
|
||||
@@ -150,7 +150,9 @@ export function RecentChatList() {
|
||||
);
|
||||
|
||||
const visibleThreads = threads.filter(
|
||||
(thread) => thread.metadata?.thread_type !== "scheduler",
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap",
|
||||
);
|
||||
|
||||
if (visibleThreads.length === 0) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
useReplaceMemoryEntry,
|
||||
} from "@/core/memory/hooks";
|
||||
import type {
|
||||
MemoryFact,
|
||||
MemorySearchMode,
|
||||
MemoryTarget,
|
||||
MemoryV2Config,
|
||||
@@ -54,6 +55,10 @@ export function MemorySettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const m = t.settings.memory;
|
||||
|
||||
// 先读配置拿到 version,再决定渲染哪套 UI
|
||||
const { configResponse } = useMemoryConfig();
|
||||
const memoryVersion = configResponse?.version ?? "v2";
|
||||
|
||||
// 工作笔记按专家隔离 —— selectedAgent 控制查看/编辑哪个专家的 MEMORY.md。
|
||||
// 用户画像 (USER.md) 是 per-user 的,不受 selectedAgent 影响。
|
||||
// 专家列表来自 agents 注册表 (非内建的才是用户的专家);default 是每个
|
||||
@@ -211,7 +216,33 @@ export function MemorySettingsPage() {
|
||||
</div>
|
||||
) : !memory ? (
|
||||
<div className="text-muted-foreground text-sm">{m.entryEmpty}</div>
|
||||
) : memoryVersion === "v1" ? (
|
||||
// V1 模式:只读展示 facts 卡片列表,支持按 agent 切换
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{m.workNotesAgentLabel}:
|
||||
</span>
|
||||
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
|
||||
<SelectTrigger className="h-8 w-44" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
{m.workNotesDefaultAgent}
|
||||
</SelectItem>
|
||||
{experts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<MemoryV1View memory={memory} />
|
||||
</div>
|
||||
) : (
|
||||
// V2 模式:USER.md / MEMORY.md 条目编辑
|
||||
<div className="space-y-4">
|
||||
{renderEntrySection(
|
||||
"user",
|
||||
@@ -251,13 +282,17 @@ export function MemorySettingsPage() {
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mt-8">
|
||||
<MemoryConfigPanel />
|
||||
</div>
|
||||
{memoryVersion !== "v1" && (
|
||||
<>
|
||||
<div className="mt-8">
|
||||
<MemoryConfigPanel />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<MemorySearchPanel />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<MemorySearchPanel />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 新增 / 编辑条目对话框 */}
|
||||
<Dialog
|
||||
@@ -415,6 +450,133 @@ function MemoryConfigPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
const CTX_LABELS: Record<string, string> = {
|
||||
workContext: "工作背景",
|
||||
personalContext: "个人信息",
|
||||
topOfMind: "当前重点",
|
||||
};
|
||||
|
||||
const HISTORY_LABELS: Record<string, string> = {
|
||||
recentMonths: "近期",
|
||||
earlierContext: "早期背景",
|
||||
longTermBackground: "长期背景",
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
preference: "bg-blue-100 text-blue-700",
|
||||
knowledge: "bg-green-100 text-green-700",
|
||||
context: "bg-yellow-100 text-yellow-700",
|
||||
behavior: "bg-purple-100 text-purple-700",
|
||||
goal: "bg-orange-100 text-orange-700",
|
||||
correction: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
/** V1 记忆只读视图:展示 memory.json 的全部字段。 */
|
||||
function MemoryV1View({
|
||||
memory,
|
||||
}: {
|
||||
memory: {
|
||||
facts: MemoryFact[];
|
||||
user_context: Record<string, string>;
|
||||
history: Record<string, string>;
|
||||
memory_usage: string;
|
||||
};
|
||||
}) {
|
||||
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
|
||||
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 说明横幅 */}
|
||||
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
||||
</div>
|
||||
|
||||
{/* 用户画像 (userContext) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">用户画像</h3>
|
||||
{ctxEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{ctxEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{CTX_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 历史摘要 (history) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">历史摘要</h3>
|
||||
{historyEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{historyEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{HISTORY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 事实记录 (facts) */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium">事实记录</h3>
|
||||
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||
</div>
|
||||
{memory.facts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
暂无事实记录,对话后系统会自动提取。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memory.facts.map((fact) => (
|
||||
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{fact.category}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
|
||||
{fact.sourceError && (
|
||||
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||
避免:{fact.sourceError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一行开关控件。 */
|
||||
function ToggleRow({
|
||||
label,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon } from "lucide-react";
|
||||
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon } from "lucide-react";
|
||||
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -107,6 +107,18 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/ai-writing")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/ai-writing">
|
||||
<PenLine />
|
||||
<span>AI写作</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface AdminMemoryBucket {
|
||||
|
||||
export interface AdminMemoryResponse {
|
||||
user_id: string;
|
||||
version: "v1" | "v2";
|
||||
buckets: AdminMemoryBucket[];
|
||||
}
|
||||
|
||||
@@ -118,3 +119,72 @@ export async function deleteAdminMemoryEntry(
|
||||
"Failed to delete memory entry",
|
||||
);
|
||||
}
|
||||
|
||||
// ── V1 Memory CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function updateAdminV1Section(
|
||||
userId: string,
|
||||
sectionType: "user" | "history",
|
||||
field: string,
|
||||
summary: string,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/sections`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ section_type: sectionType, field, summary, agent }),
|
||||
}),
|
||||
"Failed to update V1 section",
|
||||
);
|
||||
}
|
||||
|
||||
export async function addAdminV1Fact(
|
||||
userId: string,
|
||||
content: string,
|
||||
category: string,
|
||||
confidence: number,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content, category, confidence, agent }),
|
||||
}),
|
||||
"Failed to add V1 fact",
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateAdminV1Fact(
|
||||
userId: string,
|
||||
factId: string,
|
||||
updates: { content?: string; category?: string; confidence?: number },
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...updates, agent }),
|
||||
}),
|
||||
"Failed to update V1 fact",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAdminV1Fact(
|
||||
userId: string,
|
||||
factId: string,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
const params = new URLSearchParams();
|
||||
if (agent) params.set("agent", agent);
|
||||
const qs = params.toString() ? `?${params}` : "";
|
||||
return parse(
|
||||
await apiFetch(
|
||||
`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}${qs}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
"Failed to delete V1 fact",
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
addAdminMemoryEntry,
|
||||
addAdminV1Fact,
|
||||
deleteAdminMemoryEntry,
|
||||
deleteAdminV1Fact,
|
||||
getUserMemory,
|
||||
listUserThreadMessages,
|
||||
listUserThreads,
|
||||
replaceAdminMemoryEntry,
|
||||
updateAdminV1Fact,
|
||||
updateAdminV1Section,
|
||||
} from "./api";
|
||||
|
||||
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||
@@ -70,3 +74,66 @@ export function useAdminDeleteMemoryEntry(userId: string) {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
// ── V1 CRUD hooks ──────────────────────────────────────────────────────────
|
||||
|
||||
export function useAdminUpdateV1Section(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
sectionType,
|
||||
field,
|
||||
summary,
|
||||
agent,
|
||||
}: {
|
||||
sectionType: "user" | "history";
|
||||
field: string;
|
||||
summary: string;
|
||||
agent: string | null;
|
||||
}) => updateAdminV1Section(userId, sectionType, field, summary, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminAddV1Fact(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
content,
|
||||
category,
|
||||
confidence,
|
||||
agent,
|
||||
}: {
|
||||
content: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
agent: string | null;
|
||||
}) => addAdminV1Fact(userId, content, category, confidence, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUpdateV1Fact(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
factId,
|
||||
updates,
|
||||
agent,
|
||||
}: {
|
||||
factId: string;
|
||||
updates: { content?: string; category?: string; confidence?: number };
|
||||
agent: string | null;
|
||||
}) => updateAdminV1Fact(userId, factId, updates, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminDeleteV1Fact(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({ factId, agent }: { factId: string; agent: string | null }) =>
|
||||
deleteAdminV1Fact(userId, factId, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
// Memory V2 数据模型。对应后端 /api/memory/v2 系列端点。
|
||||
// Memory 数据模型。对应后端 /api/memory/v2 系列端点。支持 V1 / V2 两个版本。
|
||||
|
||||
/** builtin 本地记忆数据视图 (USER.md / MEMORY.md)。 */
|
||||
/** V1 记忆中的单条事实。 */
|
||||
export interface MemoryFact {
|
||||
id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
createdAt: string;
|
||||
/** 纠错类事实:被纠正的原错误描述(category=correction 时可能有值)。 */
|
||||
sourceError?: string;
|
||||
}
|
||||
|
||||
/** 记忆数据视图 —— V1/V2 共用结构。
|
||||
*
|
||||
* - V2 模式: user_entries / memory_entries / user_usage / memory_usage 有值
|
||||
* - V1 模式: facts / user_context / memory_usage 有值
|
||||
*/
|
||||
export interface MemoryV2Data {
|
||||
// V2 字段
|
||||
/** USER.md 条目 —— 用户画像,跨 agent 共享。 */
|
||||
user_entries: string[];
|
||||
/** MEMORY.md 条目 —— 当前 agent 的工作笔记。 */
|
||||
@@ -10,17 +26,27 @@ export interface MemoryV2Data {
|
||||
user_usage: string;
|
||||
/** MEMORY.md 字符用量。 */
|
||||
memory_usage: string;
|
||||
|
||||
// V1 字段
|
||||
/** V1 facts 列表,按置信度降序。 */
|
||||
facts: MemoryFact[];
|
||||
/** V1 userContext 摘要。 */
|
||||
user_context: Record<string, string>;
|
||||
/** V1 history 历史摘要。 */
|
||||
history: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 记忆条目所属类别。 */
|
||||
export type MemoryTarget = "user" | "memory";
|
||||
|
||||
/** 生效记忆配置 + 用户可改字段白名单。 */
|
||||
/** 生效记忆配置 + 用户可改字段白名单 + 版本号。 */
|
||||
export interface MemoryV2ConfigResponse {
|
||||
/** 按 per-user 覆盖合并后的生效配置 (嵌套 dict)。 */
|
||||
config: MemoryV2Config;
|
||||
/** 用户可在运行时覆盖的字段 (点号路径)。 */
|
||||
writable_fields: string[];
|
||||
/** 当前记忆版本:v1 或 v2。 */
|
||||
version: "v1" | "v2";
|
||||
}
|
||||
|
||||
/** 生效记忆配置的关注字段 (后端 dict 的子集)。 */
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Normalizes retrieval payloads from web_search, intranet skills, MCP tools, etc.
|
||||
* into a single card-friendly shape for the workspace chat UI.
|
||||
*
|
||||
* Parsing is intentionally tolerant: known field aliases first, then key-name
|
||||
* heuristics, then longest-string fallback for body text.
|
||||
*/
|
||||
|
||||
export type SearchResultItem = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
content?: string;
|
||||
source?: string;
|
||||
published_date?: string;
|
||||
publishedDate?: string;
|
||||
date?: string;
|
||||
};
|
||||
|
||||
const RESULT_LIST_KEYS = [
|
||||
"results",
|
||||
"data",
|
||||
"items",
|
||||
"hits",
|
||||
"documents",
|
||||
"records",
|
||||
"entries",
|
||||
"matches",
|
||||
"chunks",
|
||||
"sources",
|
||||
] as const;
|
||||
|
||||
const WRAPPER_KEYS = ["result", "payload", "response", "output", "data"] as const;
|
||||
|
||||
const NESTED_META_KEYS = ["metadata", "meta", "attrs", "attributes", "properties", "extra"] as const;
|
||||
|
||||
/** Keys ignored when guessing content from arbitrary string fields. */
|
||||
const SKIP_HEURISTIC_KEYS = new Set([
|
||||
"id",
|
||||
"uuid",
|
||||
"recuuid",
|
||||
"score",
|
||||
"relevance_score",
|
||||
"rank",
|
||||
"index",
|
||||
"type",
|
||||
"status",
|
||||
"ok",
|
||||
"success",
|
||||
"error",
|
||||
"query",
|
||||
]);
|
||||
|
||||
const TITLE_ALIASES = [
|
||||
"title",
|
||||
"m_title",
|
||||
"name",
|
||||
"heading",
|
||||
"headline",
|
||||
"subject",
|
||||
"document_title",
|
||||
"doc_title",
|
||||
"file_name",
|
||||
"filename",
|
||||
] as const;
|
||||
|
||||
const CONTENT_ALIASES = [
|
||||
"content",
|
||||
"content_preview",
|
||||
"snippet",
|
||||
"page_content",
|
||||
"body",
|
||||
"text",
|
||||
"summary",
|
||||
"abstract",
|
||||
"description",
|
||||
"desc",
|
||||
"excerpt",
|
||||
"preview",
|
||||
"passage",
|
||||
"paragraph",
|
||||
"chunk",
|
||||
"chunk_text",
|
||||
"document",
|
||||
"doc_content",
|
||||
"answer",
|
||||
"highlight",
|
||||
"context",
|
||||
"message",
|
||||
] as const;
|
||||
|
||||
const URL_ALIASES = ["url", "href", "link", "uri", "source_url", "web_url", "page_url"] as const;
|
||||
|
||||
const SOURCE_ALIASES = [
|
||||
"source",
|
||||
"source1",
|
||||
"source_name",
|
||||
"origin",
|
||||
"site",
|
||||
"site_name",
|
||||
"publisher",
|
||||
"domain",
|
||||
"from",
|
||||
"channel",
|
||||
"database",
|
||||
"knowledge_base",
|
||||
"kb",
|
||||
] as const;
|
||||
|
||||
const DATE_ALIASES = [
|
||||
"published_date",
|
||||
"publishedDate",
|
||||
"publish_date",
|
||||
"date",
|
||||
"m_publish",
|
||||
"published_at",
|
||||
"publish_time",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"time",
|
||||
] as const;
|
||||
|
||||
const TITLE_KEY_PATTERN =
|
||||
/(?:^|_)(?:title|name|heading|headline|subject)(?:$|_)/i;
|
||||
const CONTENT_KEY_PATTERN =
|
||||
/(?:^|_)(?:content|snippet|summary|abstract|body|text|preview|passage|excerpt|desc|description|chunk|paragraph|document|answer|highlight|context|message)(?:$|_)/i;
|
||||
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
|
||||
const SOURCE_KEY_PATTERN =
|
||||
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i;
|
||||
const DATE_KEY_PATTERN =
|
||||
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
|
||||
|
||||
function pickString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function looksLikeUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value) || /^www\./i.test(value);
|
||||
}
|
||||
|
||||
function collectNestedRecords(
|
||||
record: Record<string, unknown>,
|
||||
): Record<string, unknown>[] {
|
||||
const layers: Record<string, unknown>[] = [record];
|
||||
for (const key of NESTED_META_KEYS) {
|
||||
const nested = asRecord(record[key]);
|
||||
if (nested) {
|
||||
layers.push(nested);
|
||||
}
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
function pickFromLayers(
|
||||
layers: Record<string, unknown>[],
|
||||
aliases: readonly string[],
|
||||
): string | undefined {
|
||||
for (const layer of layers) {
|
||||
for (const alias of aliases) {
|
||||
const value = pickString(layer[alias]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickByKeyPattern(
|
||||
layers: Record<string, unknown>[],
|
||||
pattern: RegExp,
|
||||
options?: { excludeUrl?: boolean; maxLength?: number },
|
||||
): string | undefined {
|
||||
for (const layer of layers) {
|
||||
for (const [key, value] of Object.entries(layer)) {
|
||||
if (SKIP_HEURISTIC_KEYS.has(key.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
if (!pattern.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const text = pickString(value);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
if (options?.excludeUrl && looksLikeUrl(text)) {
|
||||
continue;
|
||||
}
|
||||
if (options?.maxLength !== undefined && text.length > options.maxLength) {
|
||||
continue;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickLongestContentFallback(
|
||||
layers: Record<string, unknown>[],
|
||||
reserved: Set<string>,
|
||||
): string | undefined {
|
||||
let best: string | undefined;
|
||||
for (const layer of layers) {
|
||||
for (const [key, value] of Object.entries(layer)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (SKIP_HEURISTIC_KEYS.has(lower)) {
|
||||
continue;
|
||||
}
|
||||
if (TITLE_KEY_PATTERN.test(key) || URL_KEY_PATTERN.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const text = pickString(value);
|
||||
if (!text || text.length < 8) {
|
||||
continue;
|
||||
}
|
||||
if (reserved.has(text)) {
|
||||
continue;
|
||||
}
|
||||
if (looksLikeUrl(text) && text.length < 120) {
|
||||
continue;
|
||||
}
|
||||
if (!best || text.length > best.length) {
|
||||
best = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function pickShortTitleFallback(
|
||||
layers: Record<string, unknown>[],
|
||||
reserved: Set<string>,
|
||||
): string | undefined {
|
||||
let best: string | undefined;
|
||||
for (const layer of layers) {
|
||||
for (const [key, value] of Object.entries(layer)) {
|
||||
if (URL_KEY_PATTERN.test(key) || CONTENT_KEY_PATTERN.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const text = pickString(value);
|
||||
if (!text || text.length > 200 || text.length < 1) {
|
||||
continue;
|
||||
}
|
||||
if (reserved.has(text)) {
|
||||
continue;
|
||||
}
|
||||
if (looksLikeUrl(text)) {
|
||||
continue;
|
||||
}
|
||||
if (!best || text.length < best.length) {
|
||||
best = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
const record = asRecord(raw);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layers = collectNestedRecords(record);
|
||||
|
||||
let title =
|
||||
pickFromLayers(layers, TITLE_ALIASES) ??
|
||||
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
|
||||
let content =
|
||||
pickFromLayers(layers, CONTENT_ALIASES) ??
|
||||
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
|
||||
let url =
|
||||
pickFromLayers(layers, URL_ALIASES) ??
|
||||
pickByKeyPattern(layers, URL_KEY_PATTERN);
|
||||
let source =
|
||||
pickFromLayers(layers, SOURCE_ALIASES) ??
|
||||
pickByKeyPattern(layers, SOURCE_KEY_PATTERN, {
|
||||
excludeUrl: true,
|
||||
maxLength: 120,
|
||||
});
|
||||
const date =
|
||||
pickFromLayers(layers, DATE_ALIASES) ??
|
||||
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
|
||||
|
||||
const reserved = new Set<string>();
|
||||
for (const value of [title, content, url, source, date]) {
|
||||
if (value) {
|
||||
reserved.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
content = pickLongestContentFallback(layers, reserved);
|
||||
if (content) {
|
||||
reserved.add(content);
|
||||
}
|
||||
}
|
||||
if (!title) {
|
||||
title = pickShortTitleFallback(layers, reserved);
|
||||
}
|
||||
|
||||
if (!title && !content && !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
url,
|
||||
snippet: content,
|
||||
content,
|
||||
source,
|
||||
published_date: date,
|
||||
publishedDate: date,
|
||||
date,
|
||||
};
|
||||
}
|
||||
|
||||
function extractRawResults(payload: unknown, depth = 0): unknown[] | null {
|
||||
if (depth > 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.length > 0 ? payload : null;
|
||||
}
|
||||
|
||||
const record = asRecord(payload);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of RESULT_LIST_KEYS) {
|
||||
const nested = record[key];
|
||||
if (Array.isArray(nested) && nested.length > 0) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of WRAPPER_KEYS) {
|
||||
const nested = record[key];
|
||||
const found = extractRawResults(nested, depth + 1);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseResultPayload(
|
||||
result: string | Record<string, unknown> | undefined,
|
||||
): unknown {
|
||||
if (result === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof result === "string") {
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** True when the tool return value looks like a retrieval hit list. */
|
||||
export function hasSearchResultPayload(
|
||||
result: string | Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
return normalizeSearchResults(result).length > 0;
|
||||
}
|
||||
|
||||
export function normalizeSearchResults(
|
||||
result: string | Record<string, unknown> | undefined,
|
||||
): SearchResultItem[] {
|
||||
const payload = parseResultPayload(result);
|
||||
const rawList = extractRawResults(payload);
|
||||
if (!rawList) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: SearchResultItem[] = [];
|
||||
for (const raw of rawList) {
|
||||
const item = normalizeSearchResultItem(raw);
|
||||
if (item) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function resolveSearchQuery(
|
||||
args: Record<string, unknown>,
|
||||
result: string | Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
if (typeof args.query === "string" && args.query.trim()) {
|
||||
return args.query.trim();
|
||||
}
|
||||
const payload = asRecord(parseResultPayload(result));
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
const queryKeys = ["query", "q", "keyword", "keywords", "search_query"] as const;
|
||||
for (const key of queryKeys) {
|
||||
const value = payload[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -506,6 +506,7 @@ export function useThreadStream({
|
||||
: context.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,11 +14,12 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
model_name: string | undefined;
|
||||
thinking_enabled: boolean;
|
||||
is_plan_mode: boolean;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
agent_id?: string;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
||||
// ── Edit mode ─────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<WritingToolbar editor={editor} />
|
||||
<WritingToolbar editor={editor} markdown={content.fullMarkdown} title={content.title} />
|
||||
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
Quote,
|
||||
Code,
|
||||
Pilcrow,
|
||||
Download,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
||||
import { exportMarkdownToDocx } from "../../utils/export-docx";
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -60,11 +65,32 @@ function ToolbarButton({ onClick, active, disabled, tooltip, children }: Toolbar
|
||||
|
||||
interface WritingToolbarProps {
|
||||
editor: Editor | null;
|
||||
/** 文档源(markdown),用于导出 Word;空字符串时下载按钮禁用 */
|
||||
markdown?: string;
|
||||
/** 文档标题,用作 .docx 文件名与封面 */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function WritingToolbar({ editor }: WritingToolbarProps) {
|
||||
export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const canExport = !exporting && !!markdown?.trim();
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!canExport) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportMarkdownToDocx(markdown ?? "", title);
|
||||
} catch (err) {
|
||||
console.error("[export-docx] failed", err);
|
||||
toast.error("导出 Word 失败,请稍后重试");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
|
||||
{/* Block type */}
|
||||
@@ -173,8 +199,37 @@ export function WritingToolbar({ editor }: WritingToolbarProps) {
|
||||
<Strikethrough className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
{/* Model selector — pushed to the right */}
|
||||
<div className="ml-auto">
|
||||
{/* Right side: export + model selector */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleExport();
|
||||
}}
|
||||
disabled={!canExport}
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-40",
|
||||
)}
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
导出 Word
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-4" />
|
||||
|
||||
<CanvasModelSelector />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Markdown → Word(.docx) 导出工具。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 纯前端,避免新增后端接口;输入是 AI 写作页右侧 artifact 的 fullMarkdown。
|
||||
* - 文档结构:封面标题(无 H1 时补) → 正文。
|
||||
*/
|
||||
import {
|
||||
AlignmentType,
|
||||
Document,
|
||||
ExternalHyperlink,
|
||||
HeadingLevel,
|
||||
LevelFormat,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
} from "docx";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
/** 标题层级(H1~H3) */
|
||||
type HeadingLevelNum = 1 | 2 | 3;
|
||||
|
||||
interface ParsedHeading {
|
||||
level: HeadingLevelNum;
|
||||
text: string;
|
||||
}
|
||||
|
||||
type Block =
|
||||
| { kind: "heading"; level: HeadingLevelNum; text: string }
|
||||
| { kind: "paragraph"; text: string }
|
||||
| { kind: "blockquote"; text: string }
|
||||
| { kind: "code"; code: string }
|
||||
| { kind: "list"; ordered: boolean; items: string[] };
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Markdown 解析(行级)—— 只覆盖 AI 写作场景常见语法
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const RE_HEADING = /^(#{1,3})\s+(.*)$/;
|
||||
const RE_FENCE = /^```/;
|
||||
const RE_QUOTE = /^>\s?/;
|
||||
const RE_UL = /^[-*]\s+/;
|
||||
const RE_OL = /^\d+\.\s+/;
|
||||
const RE_BLOCK_START = /^(#{1,3}\s|[-*]\s|\d+\.\s|>\s|```)/;
|
||||
|
||||
function parseMarkdown(markdown: string): { blocks: Block[]; headings: ParsedHeading[] } {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const blocks: Block[] = [];
|
||||
const headings: ParsedHeading[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (!line.trim()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RE_FENCE.test(line)) {
|
||||
const buf: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !RE_FENCE.test(lines[i])) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) i++; // 跳过闭合 ```
|
||||
blocks.push({ kind: "code", code: buf.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hm = RE_HEADING.exec(line);
|
||||
if (hm) {
|
||||
const level = hm[1].length as HeadingLevelNum;
|
||||
const text = hm[2].trim();
|
||||
headings.push({ level, text });
|
||||
blocks.push({ kind: "heading", level, text });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RE_QUOTE.test(line)) {
|
||||
const buf: string[] = [];
|
||||
while (i < lines.length && RE_QUOTE.test(lines[i])) {
|
||||
buf.push(lines[i].replace(RE_QUOTE, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "blockquote", text: buf.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RE_UL.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && RE_UL.test(lines[i])) {
|
||||
items.push(lines[i].replace(RE_UL, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "list", ordered: false, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RE_OL.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && RE_OL.test(lines[i])) {
|
||||
items.push(lines[i].replace(RE_OL, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "list", ordered: true, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 段落:合并相邻非空、非块起始行
|
||||
const buf: string[] = [line];
|
||||
i++;
|
||||
while (i < lines.length && lines[i].trim() && !RE_BLOCK_START.test(lines[i])) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "paragraph", text: buf.join("\n") });
|
||||
}
|
||||
|
||||
return { blocks, headings };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 行内格式 → TextRun / Hyperlink
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 行内 token 匹配顺序很重要:**bold** 必须先于 *italic*;`code` 与 [link]() 独立。 */
|
||||
const RE_INLINE = /(\*\*[^*\n]+?\*\*)|(\*[^*\n]+?\*)|(_[^_\n]+?_)|(`[^`\n]+?`)|(\[[^\]\n]+\]\([^)\n]+\))/g;
|
||||
|
||||
type InlineChild = TextRun | ExternalHyperlink;
|
||||
|
||||
function parseInline(text: string): InlineChild[] {
|
||||
if (!text) return [new TextRun({ text: "" })];
|
||||
|
||||
const out: InlineChild[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
RE_INLINE.lastIndex = 0;
|
||||
|
||||
while ((m = RE_INLINE.exec(text)) !== null) {
|
||||
if (m.index > last) {
|
||||
out.push(new TextRun({ text: text.slice(last, m.index) }));
|
||||
}
|
||||
const tok = m[0];
|
||||
if (tok.startsWith("**")) {
|
||||
out.push(new TextRun({ text: tok.slice(2, -2), bold: true }));
|
||||
} else if (tok.startsWith("`")) {
|
||||
out.push(new TextRun({ text: tok.slice(1, -1), font: "Consolas" }));
|
||||
} else if (tok.startsWith("[")) {
|
||||
const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok);
|
||||
if (linkMatch) {
|
||||
out.push(
|
||||
new ExternalHyperlink({
|
||||
link: linkMatch[2],
|
||||
children: [
|
||||
new TextRun({
|
||||
text: linkMatch[1],
|
||||
color: "0563C1",
|
||||
underline: {},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
out.push(new TextRun({ text: tok }));
|
||||
}
|
||||
} else if (tok.startsWith("*") || tok.startsWith("_")) {
|
||||
out.push(new TextRun({ text: tok.slice(1, -1), italics: true }));
|
||||
}
|
||||
last = RE_INLINE.lastIndex;
|
||||
}
|
||||
if (last < text.length) {
|
||||
out.push(new TextRun({ text: text.slice(last) }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Block → Paragraph[]
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DOCX_HEADING: Record<HeadingLevelNum, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
|
||||
1: HeadingLevel.HEADING_1,
|
||||
2: HeadingLevel.HEADING_2,
|
||||
3: HeadingLevel.HEADING_3,
|
||||
};
|
||||
|
||||
function blockToParagraphs(b: Block): Paragraph[] {
|
||||
switch (b.kind) {
|
||||
case "heading": {
|
||||
return [
|
||||
new Paragraph({
|
||||
heading: DOCX_HEADING[b.level],
|
||||
children: parseInline(b.text),
|
||||
}),
|
||||
];
|
||||
}
|
||||
case "paragraph": {
|
||||
return [
|
||||
new Paragraph({
|
||||
children: parseInline(b.text),
|
||||
spacing: { line: 360, after: 120 },
|
||||
}),
|
||||
];
|
||||
}
|
||||
case "blockquote": {
|
||||
return [
|
||||
new Paragraph({
|
||||
children: parseInline(b.text),
|
||||
indent: { left: 480 },
|
||||
spacing: { line: 360, after: 120 },
|
||||
}),
|
||||
];
|
||||
}
|
||||
case "code": {
|
||||
return b.code.split("\n").map(
|
||||
(line) =>
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: line || " ", font: "Consolas", size: 20 })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
return b.items.map(
|
||||
(item) =>
|
||||
new Paragraph({
|
||||
numbering: {
|
||||
reference: b.ordered ? "ol" : "ul",
|
||||
level: 0,
|
||||
},
|
||||
children: parseInline(item),
|
||||
spacing: { line: 320 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 文件名工具
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
// Windows 非法字符 + 控制字符 → 下划线
|
||||
return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").trim();
|
||||
}
|
||||
|
||||
function fallbackFilename(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `AI写作-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function resolveFilename(title: string | undefined): string {
|
||||
const base = title ? sanitizeFilename(title) : "";
|
||||
return `${base || fallbackFilename()}.docx`;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 主入口
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 把 markdown 文档导出为 .docx 并触发浏览器下载。
|
||||
*
|
||||
* @param markdown 文档源(来自 AI 写作 artifact 的 fullMarkdown)
|
||||
* @param title 文档标题,用作文件名与封面(空则用时间戳兜底)
|
||||
*/
|
||||
export async function exportMarkdownToDocx(
|
||||
markdown: string,
|
||||
title?: string,
|
||||
): Promise<void> {
|
||||
const { blocks, headings } = parseMarkdown(markdown ?? "");
|
||||
|
||||
// 封面标题:优先用入参 title;否则用 markdown 里的第一个 H1
|
||||
const coverText =
|
||||
(title && title.trim()) ||
|
||||
headings.find((h) => h.level === 1)?.text ||
|
||||
"未命名文档";
|
||||
|
||||
const children: Paragraph[] = [];
|
||||
|
||||
// 封面标题(仅当 markdown 本身没有 H1 时补一个,避免与正文里的 H1 重复)
|
||||
const hasFirstH1 = headings[0]?.level === 1;
|
||||
if (!hasFirstH1) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 240, after: 240 },
|
||||
children: [new TextRun({ text: coverText, bold: true, size: 36 })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 正文
|
||||
for (const b of blocks) {
|
||||
children.push(...blockToParagraphs(b));
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
creator: "DeerFlow AI 写作",
|
||||
title: coverText,
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: { font: "微软雅黑", size: 22 }, // 默认正文 11pt 微软雅黑
|
||||
},
|
||||
},
|
||||
},
|
||||
numbering: {
|
||||
config: [
|
||||
{
|
||||
reference: "ul",
|
||||
levels: [
|
||||
{
|
||||
level: 0,
|
||||
format: LevelFormat.BULLET,
|
||||
text: "•",
|
||||
alignment: AlignmentType.LEFT,
|
||||
style: { paragraph: { indent: { left: 360, hanging: 240 } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
reference: "ol",
|
||||
levels: [
|
||||
{
|
||||
level: 0,
|
||||
format: LevelFormat.DECIMAL,
|
||||
text: "%1.",
|
||||
alignment: AlignmentType.LEFT,
|
||||
style: { paragraph: { indent: { left: 360, hanging: 240 } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [{ children }],
|
||||
});
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
saveAs(blob, resolveFilename(title));
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export default function NewAgentPage() {
|
||||
if (threadPrepared) {
|
||||
return;
|
||||
}
|
||||
await getAPIClient().threads.create({ threadId });
|
||||
await getAPIClient().threads.create({ threadId, metadata: { thread_type: "bootstrap" } });
|
||||
setThreadPrepared(true);
|
||||
}, [threadId, threadPrepared]);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import AdminMemoryPage from "./AdminMemoryPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
||||
|
||||
export function WorkspaceLayout({
|
||||
children,
|
||||
@@ -177,6 +178,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="ai-writing"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AIWritingPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
|
||||
Reference in new issue
Block a user