前端: - Wiki 标签与实体改为可点击,弹出 RelatedNotesDialog 查看关联知识 - 知识库首次进入自动打开最近笔记,避免空白阅读区 - 流式重连识别 "Run not found",清理 sessionStorage 残留 key 并静默回退历史 后端: - 线程鉴权区分 not found / not accessible,并支持 DB 切换产生的孤儿线程认领 - run 查询/join 在内存未命中时回退持久化存储,重启后返回干净的终止 SSE 而非 404
406 lines
16 KiB
TypeScript
406 lines
16 KiB
TypeScript
import { ExternalLink, Sparkles } from "lucide-react";
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { useKnowledgeActions } from "@/core/knowledge/hooks";
|
||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||
import { cn } from "@/lib/utils";
|
||
import type {
|
||
WikiLinkMap,
|
||
WikiLinkTarget,
|
||
} from "@/strategy-components/MarkdownRenderer";
|
||
|
||
import { Breadcrumb } from "./Breadcrumb";
|
||
import { DocumentToolbar } from "./DocumentToolbar";
|
||
import { FloatingToc } from "./FloatingToc";
|
||
import { MarkdownViewer } from "./MarkdownViewer";
|
||
import { RelatedDocs } from "./RelatedDocs";
|
||
import {
|
||
RelatedNotesDialog,
|
||
type RelatedNotesFilter,
|
||
} from "./RelatedNotesDialog";
|
||
import {
|
||
SOURCE_TYPE_LABELS,
|
||
STATUS_LABELS,
|
||
downloadMarkdown,
|
||
extractToc,
|
||
formatDateTime,
|
||
noteBreadcrumbSegments,
|
||
splitRelatedSection,
|
||
} from "./wikiUtils";
|
||
|
||
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
|
||
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
|
||
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
|
||
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
|
||
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
|
||
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
|
||
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
|
||
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
|
||
};
|
||
|
||
function entityMeta(type?: string | null) {
|
||
return (
|
||
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
|
||
label: "其他",
|
||
className:
|
||
"bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
|
||
}
|
||
);
|
||
}
|
||
|
||
/** Human label for an audit actor: prefer the resolved name (email), else a short id. */
|
||
function actorLabel(name?: string | null, id?: string | null): string {
|
||
if (name && name.trim()) return name.trim();
|
||
if (id && id.trim()) return `${id.trim().slice(0, 8)}…`;
|
||
return "—";
|
||
}
|
||
|
||
function MetaTag({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<span className="inline-flex h-6 items-center rounded-md bg-slate-100 px-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** Middle column: compact header + markdown reading area (or inline editor). */
|
||
export function WikiContent({
|
||
note,
|
||
wikiLinks,
|
||
onWikiLinkClick,
|
||
}: {
|
||
note: KnowledgeNote;
|
||
wikiLinks?: WikiLinkMap;
|
||
onWikiLinkClick: (link: WikiLinkTarget) => void;
|
||
}) {
|
||
const navigate = useNavigate();
|
||
const { update, remove } = useKnowledgeActions();
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const tickingRef = useRef(false);
|
||
|
||
const [editing, setEditing] = useState(false);
|
||
const [draftTitle, setDraftTitle] = useState(note.title);
|
||
const [draftTags, setDraftTags] = useState(note.tags.join(", "));
|
||
const [draftContent, setDraftContent] = useState(note.content_md);
|
||
const [draftStatus, setDraftStatus] = useState<string>(note.status);
|
||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||
// Tag / entity chip clicked → show notes that share it.
|
||
const [relatedFilter, setRelatedFilter] = useState<RelatedNotesFilter | null>(
|
||
null,
|
||
);
|
||
|
||
// Pull the backend-injected `## Related` wikilinks out of the body so they
|
||
// render as a structured module; editing still works on the full source.
|
||
const { body, related } = useMemo(
|
||
() => splitRelatedSection(note.content_md),
|
||
[note.content_md],
|
||
);
|
||
const toc = useMemo(() => extractToc(body), [body]);
|
||
// The floating outline only shows h2/h3 — deeper levels are noise.
|
||
const outlineItems = useMemo(
|
||
() => toc.filter((item) => item.level === 2 || item.level === 3),
|
||
[toc],
|
||
);
|
||
|
||
useEffect(() => {
|
||
setEditing(false);
|
||
setDraftTitle(note.title);
|
||
setDraftTags(note.tags.join(", "));
|
||
setDraftContent(note.content_md);
|
||
setDraftStatus(note.status);
|
||
scrollRef.current?.scrollTo({ top: 0 });
|
||
setActiveHeadingId(null);
|
||
// Re-sync drafts whenever a different note (or fresh server copy) arrives.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [note.id, note.updated_at]);
|
||
|
||
const handleScroll = () => {
|
||
if (tickingRef.current) return;
|
||
tickingRef.current = true;
|
||
window.requestAnimationFrame(() => {
|
||
tickingRef.current = false;
|
||
const container = scrollRef.current;
|
||
if (!container) return;
|
||
const headings = container.querySelectorAll<HTMLElement>(
|
||
".wiki-markdown h1[id], .wiki-markdown h2[id], .wiki-markdown h3[id], .wiki-markdown h4[id]",
|
||
);
|
||
if (headings.length === 0) return;
|
||
const containerTop = container.getBoundingClientRect().top;
|
||
let current: string | null = headings[0].id;
|
||
for (const el of headings) {
|
||
if (el.getBoundingClientRect().top - containerTop <= 96) {
|
||
current = el.id;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
setActiveHeadingId(current);
|
||
});
|
||
};
|
||
|
||
const handleTocClick = (id: string) => {
|
||
setActiveHeadingId(id);
|
||
document
|
||
.getElementById(id)
|
||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
await update.mutateAsync({
|
||
noteId: note.id,
|
||
data: {
|
||
title: draftTitle.trim() || note.title,
|
||
content_md: draftContent,
|
||
tags: draftTags
|
||
.split(/[,,\s]+/)
|
||
.map((t) => t.trim())
|
||
.filter(Boolean),
|
||
status: draftStatus,
|
||
},
|
||
});
|
||
toast.success("已保存");
|
||
setEditing(false);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||
}
|
||
};
|
||
|
||
const handleArchive = async () => {
|
||
try {
|
||
await remove.mutateAsync({ noteId: note.id });
|
||
toast.success("已归档");
|
||
navigate("/page/workspace/knowledge");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "归档失败");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="flex min-h-0 min-w-0 flex-col bg-white dark:bg-slate-900">
|
||
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
|
||
<Breadcrumb
|
||
segments={noteBreadcrumbSegments(note)}
|
||
current={note.title}
|
||
className="min-w-0 flex-1"
|
||
/>
|
||
<DocumentToolbar
|
||
charCount={note.content_md.length}
|
||
updatedAt={formatDateTime(note.updated_at)}
|
||
statusLabel={STATUS_LABELS[note.status] ?? note.status}
|
||
editing={editing}
|
||
saving={update.isPending}
|
||
archiving={remove.isPending}
|
||
onEdit={() => setEditing(true)}
|
||
onCancelEdit={() => setEditing(false)}
|
||
onSave={() => void handleSave()}
|
||
onExport={() => downloadMarkdown(note)}
|
||
onArchive={() => void handleArchive()}
|
||
/>
|
||
</header>
|
||
|
||
<div className="relative min-h-0 flex-1">
|
||
{!editing ? (
|
||
<FloatingToc
|
||
items={outlineItems}
|
||
activeId={activeHeadingId}
|
||
onItemClick={handleTocClick}
|
||
className="absolute right-4 top-3 z-10 sm:right-6"
|
||
/>
|
||
) : null}
|
||
|
||
<div
|
||
ref={scrollRef}
|
||
onScroll={editing ? undefined : handleScroll}
|
||
className="h-full overflow-y-auto"
|
||
>
|
||
<div className="mx-auto w-full max-w-[980px] px-5 pb-28 pt-8 sm:px-8 lg:px-10">
|
||
{editing ? (
|
||
<div className="flex flex-col gap-3">
|
||
<Input
|
||
value={draftTitle}
|
||
onChange={(e) => setDraftTitle(e.target.value)}
|
||
placeholder="标题"
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
value={draftTags}
|
||
onChange={(e) => setDraftTags(e.target.value)}
|
||
placeholder="标签,逗号分隔"
|
||
/>
|
||
<Select value={draftStatus} onValueChange={setDraftStatus}>
|
||
<SelectTrigger className="w-32">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="draft">草稿</SelectItem>
|
||
<SelectItem value="approved">已批准</SelectItem>
|
||
<SelectItem value="archived">已归档</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<Textarea
|
||
rows={24}
|
||
value={draftContent}
|
||
onChange={(e) => setDraftContent(e.target.value)}
|
||
className="font-mono text-sm"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<h1 className="mb-2 text-2xl font-bold leading-snug text-slate-900 dark:text-slate-100">
|
||
{note.title}
|
||
</h1>
|
||
|
||
{note.summary ? (
|
||
<div className="my-4 rounded-lg border-l-2 border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-7 text-slate-600 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-300">
|
||
{note.summary}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mb-7 mt-3 flex flex-wrap items-center gap-1.5">
|
||
<MetaTag>
|
||
来源:{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
|
||
</MetaTag>
|
||
<MetaTag>状态:{STATUS_LABELS[note.status] ?? note.status}</MetaTag>
|
||
{note.created_at ? (
|
||
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
|
||
) : null}
|
||
{note.tags.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
title={`查看「${tag}」的关联知识`}
|
||
onClick={() => setRelatedFilter({ kind: "tag", value: tag })}
|
||
className="inline-flex h-6 cursor-pointer items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 transition-colors hover:bg-rose-100 hover:text-rose-600 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20"
|
||
>
|
||
#{tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{note.entities && note.entities.length > 0 ? (
|
||
<div className="mb-7 flex flex-wrap items-center gap-1.5">
|
||
<span className="mr-0.5 inline-flex items-center gap-1 text-xs text-slate-400">
|
||
<Sparkles className="size-3" /> 实体
|
||
</span>
|
||
{note.entities.map((ent) => {
|
||
const meta = entityMeta(ent.entity_type);
|
||
return (
|
||
<button
|
||
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
||
type="button"
|
||
title={`查看与「${ent.name}」(${meta.label})相关的知识`}
|
||
onClick={() =>
|
||
setRelatedFilter({ kind: "entity", value: ent.name })
|
||
}
|
||
className={cn(
|
||
"inline-flex cursor-pointer items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium transition-opacity hover:opacity-80",
|
||
meta.className,
|
||
)}
|
||
>
|
||
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
|
||
<span>{ent.name}</span>
|
||
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
|
||
{meta.label}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
|
||
<MarkdownViewer
|
||
content={body}
|
||
toc={toc}
|
||
wikiLinks={wikiLinks}
|
||
onWikiLinkClick={onWikiLinkClick}
|
||
/>
|
||
|
||
<RelatedDocs
|
||
links={related}
|
||
wikiLinks={wikiLinks}
|
||
onOpen={onWikiLinkClick}
|
||
/>
|
||
|
||
{note.sources && note.sources.length > 0 ? (
|
||
<div className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
|
||
<h2 className="mb-3 text-sm font-semibold text-slate-600 dark:text-slate-300">
|
||
来源
|
||
</h2>
|
||
<ul className="flex flex-col gap-1.5">
|
||
{note.sources.map((src) => (
|
||
<li key={src.id} className="text-[13px]">
|
||
{src.url ? (
|
||
<a
|
||
href={src.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
|
||
>
|
||
{src.title || src.url}
|
||
<ExternalLink className="size-3" />
|
||
</a>
|
||
) : src.thread_id ? (
|
||
<button
|
||
type="button"
|
||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
||
onClick={() =>
|
||
navigate(`/page/workspace/chats/${src.thread_id}`)
|
||
}
|
||
>
|
||
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
|
||
</button>
|
||
) : (
|
||
<span className="text-slate-600 dark:text-slate-300">
|
||
{src.title || src.tool_name || "来源"}
|
||
</span>
|
||
)}
|
||
{src.snippet ? (
|
||
<p className="mt-0.5 line-clamp-2 text-xs text-slate-400">
|
||
{src.snippet}
|
||
</p>
|
||
) : null}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-12 border-t border-slate-100 pt-3 text-[12px] text-slate-400/90 dark:border-slate-800 dark:text-slate-500">
|
||
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
|
||
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
|
||
{formatDateTime(note.updated_at)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<RelatedNotesDialog
|
||
filter={relatedFilter}
|
||
currentNoteId={note.id}
|
||
onOpenChange={(open) => {
|
||
if (!open) setRelatedFilter(null);
|
||
}}
|
||
onSelect={(id) => {
|
||
setRelatedFilter(null);
|
||
navigate(`/page/workspace/knowledge/${id}`);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|