feat: 知识库标签/实体关联跳转 + 会话重连容错

前端:
- Wiki 标签与实体改为可点击,弹出 RelatedNotesDialog 查看关联知识
- 知识库首次进入自动打开最近笔记,避免空白阅读区
- 流式重连识别 "Run not found",清理 sessionStorage 残留 key 并静默回退历史

后端:
- 线程鉴权区分 not found / not accessible,并支持 DB 切换产生的孤儿线程认领
- run 查询/join 在内存未命中时回退持久化存储,重启后返回干净的终止 SSE 而非 404
This commit is contained in:
Administrator committed 2026-06-11 23:19:09 +08:00
1 parent 76e2e67af8
commit 1b86373a54
8 files changed
+357 -23

No files matched your search

@@ -0,0 +1,110 @@
import { FileText, Hash, Loader2, Sparkles } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useKnowledgeNotes } from "@/core/knowledge/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { SOURCE_TYPE_LABELS, formatDateTime } from "./wikiUtils";
/** What was clicked: a tag (exact filter) or an entity (keyword match in body). */
export type RelatedNotesFilter =
| { kind: "tag"; value: string }
| { kind: "entity"; value: string };
/**
* Click a tag / entity chip → list every other note that shares it.
* Tags use the backend's exact `tag` filter; entities fall back to a keyword
* match (entity names appear verbatim in titles/summaries/bodies).
*/
export function RelatedNotesDialog({
filter,
currentNoteId,
onOpenChange,
onSelect,
}: {
filter: RelatedNotesFilter | null;
currentNoteId?: string;
onOpenChange: (open: boolean) => void;
onSelect: (noteId: string) => void;
}) {
const params =
filter?.kind === "tag"
? { tag: filter.value, limit: 50 }
: filter?.kind === "entity"
? { keyword: filter.value, limit: 50 }
: { limit: 0 };
const { data, isLoading, isFetching } = useKnowledgeNotes(params);
const items = (data?.items ?? []).filter(
(n: KnowledgeNote) => n.id !== currentNoteId,
);
return (
<Dialog open={!!filter} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{filter?.kind === "entity" ? (
<Sparkles className="size-4 text-violet-500" />
) : (
<Hash className="size-4 text-rose-500" />
)}
<span className="truncate">{filter?.value}</span>
</DialogTitle>
<DialogDescription>
{filter?.kind === "entity"
? "提到该实体的关联知识"
: "使用该标签的关联知识"}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[55vh]">
{isLoading || (isFetching && items.length === 0) ? (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
暂无其他关联知识。
</div>
) : (
<ul className="flex flex-col gap-1 pr-2">
{items.map((note) => (
<li key={note.id}>
<button
type="button"
onClick={() => onSelect(note.id)}
className="flex w-full flex-col gap-0.5 rounded-md border border-transparent px-3 py-2 text-left transition-colors hover:border-slate-200 hover:bg-slate-50 dark:hover:border-slate-700 dark:hover:bg-slate-800/60"
>
<span className="flex items-center gap-2 text-sm font-medium text-slate-800 dark:text-slate-100">
<FileText className="size-3.5 shrink-0 text-slate-400" />
<span className="truncate">{note.title}</span>
</span>
{note.summary ? (
<span className="line-clamp-2 pl-[1.375rem] text-xs text-slate-500 dark:text-slate-400">
{note.summary}
</span>
) : null}
<span className="text-muted-foreground pl-[1.375rem] text-[11px]">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
{note.updated_at
? ` · ${formatDateTime(note.updated_at).slice(0, 10)}`
: ""}
</span>
</button>
</li>
))}
</ul>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
@@ -25,6 +25,10 @@ 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,
@@ -91,6 +95,10 @@ export function WikiContent({
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.
@@ -270,12 +278,15 @@ export function WikiContent({
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null}
{note.tags.map((tag) => (
<span
<button
key={tag}
className="inline-flex h-6 items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
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}
</span>
</button>
))}
</div>
@@ -287,11 +298,15 @@ export function WikiContent({
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
<button
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
type="button"
title={`查看与「${ent.name}」(${meta.label})相关的知识`}
onClick={() =>
setRelatedFilter({ kind: "entity", value: ent.name })
}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
"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,
)}
>
@@ -300,7 +315,7 @@ export function WikiContent({
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</span>
</button>
);
})}
</div>
@@ -373,6 +388,18 @@ export function WikiContent({
</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>
);
}