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>
);
}
+32 -1
View File
@@ -152,7 +152,10 @@ function getStreamErrorMessage(error: unknown): string {
*/
function isThreadNotAccessibleError(message: string): boolean {
const lowered = message.toLowerCase();
if (/thread\s+[\w-]{8,}\s+not\s+found/i.test(message)) {
// Backend now returns "not found" (no row + no checkpoint) and
// "not accessible" (row owned by another user / stale login session) as
// distinct details — both mean the current user can't use this thread.
if (/thread\s+[\w-]{8,}\s+not\s+(found|accessible)/i.test(message)) {
return true;
}
// Fallback: a bare 404/403 from the threads endpoint when the SDK has
@@ -163,6 +166,21 @@ function isThreadNotAccessibleError(message: string): boolean {
);
}
/**
* Detect a "Run <id> not found" 404 from the LangGraph SDK.
*
* The SDK persists the in-flight run id under ``lg:stream:{threadId}`` in
* sessionStorage and, on mount, auto-``joinStream``s it to resume a dropped
* SSE. After a backend restart the in-memory run registry is gone, so the join
* 404s — and the SDK only clears that key on reconnect *success*, never on
* failure. So every switch back into the conversation re-fires the same 404.
* We detect it here to clear the stale key ourselves and stay silent (the
* conversation renders from history; there is nothing live to reconnect to).
*/
function isRunNotFoundError(message: string): boolean {
return /run\s+[\w-]{8,}\s+not\s+found/i.test(message);
}
function buildCurrentUserLabel(): string {
const auth = getStoredAuth();
if (!auth) {
@@ -335,6 +353,19 @@ export function useThreadStream({
onError(error) {
setOptimisticMessages([]);
const message = getStreamErrorMessage(error);
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
// conversation stops re-firing the same 404, and render from history
// silently — there is no live run to rejoin.
if (isRunNotFoundError(message)) {
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (staleThreadId && runMetadataStorageRef.current) {
runMetadataStorageRef.current.removeItem(
`lg:stream:${staleThreadId}`,
);
}
return;
}
// A "thread not found" 404 almost always means the URL points at a
// thread from a previous login session — either this tab outlived a
// logout/re-login or the thread genuinely belongs to a different user.
@@ -468,6 +468,8 @@ export default function KnowledgeBasePage() {
const [status, setStatus] = useState("all");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const treeInitializedRef = useRef(false);
// Auto-open a default note once on entry so the reading pane is never blank.
const defaultNoteShownRef = useRef(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed);
@@ -495,6 +497,15 @@ export default function KnowledgeBasePage() {
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
// On first entry without an explicit note, open the most recent one so the
// reading pane shows real content instead of the empty placeholder.
useEffect(() => {
if (defaultNoteShownRef.current || noteId || isLoading) return;
if (notes.length === 0) return;
defaultNoteShownRef.current = true;
navigate(`/page/workspace/knowledge/${notes[0].id}`, { replace: true });
}, [noteId, isLoading, notes, navigate]);
// First load: expand the root-level folders so the tree reads as a directory.
useEffect(() => {
if (treeInitializedRef.current || tree.length === 0) return;
@@ -31,21 +31,43 @@ from __future__ import annotations
import functools
import inspect
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
import logging
from collections.abc import Callable
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
from fastapi import HTTPException, Request
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
if TYPE_CHECKING:
from app.gateway.auth.models import User
logger = logging.getLogger(__name__)
P = ParamSpec("P")
T = TypeVar("T")
async def _thread_checkpoint_exists(request: Request, thread_id: str) -> bool:
"""Return True if a LangGraph checkpoint exists for ``thread_id``.
A checkpoint with no matching ``threads_meta`` row is the signature of a
DB-switch orphan (sqlite↔mysql/postgres juggling): the conversation state
survives in the checkpointer while the business row is gone. Normal thread
deletion removes the checkpoint too, so this can never resurrect a
deliberately-deleted thread.
"""
try:
from app.gateway.deps import get_checkpointer
checkpointer = get_checkpointer(request)
tup = await checkpointer.aget_tuple({"configurable": {"thread_id": thread_id}})
return tup is not None
except Exception:
return False
# Permission constants
class Permissions:
"""Permission constants for resource:action format."""
@@ -205,6 +227,7 @@ def require_permission(
action: str,
owner_check: bool = False,
require_existing: bool = False,
claim_orphan: bool = False,
) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Decorator that checks permission for resource:action.
@@ -221,6 +244,15 @@ def require_permission(
**destructive / mutating** routes (DELETE, PATCH,
state-update) so a deleted thread can't be re-targeted
by another user via the missing-row code path.
claim_orphan: Only meaningful with ``owner_check=True`` and
``require_existing=True``. When the ``threads_meta``
row is missing **and** a LangGraph checkpoint exists
for the thread (a DB-switch orphan), adopt it for the
current user instead of 404ing. Use on **run-create**
routes so a conversation whose business row was lost
(sqlite↔mysql swap) stays answerable. Safe: normal
deletion removes the checkpoint, so this never revives
a deliberately-deleted thread.
Usage:
# Read-style: legacy untracked threads are allowed
@@ -289,16 +321,44 @@ def require_permission(
from app.gateway.deps import get_thread_store
thread_store = get_thread_store(request)
user_id = str(auth.user.id)
allowed = await thread_store.check_access(
thread_id,
str(auth.user.id),
user_id,
require_existing=require_existing,
)
if not allowed:
raise HTTPException(
status_code=404,
detail=f"Thread {thread_id} not found",
)
# Tell apart three denial causes so the client gets an
# accurate message and so DB-switch orphans can be adopted
# rather than hard-404'd:
# 1. row exists but owned by another user -> not accessible
# 2. no row, no checkpoint -> truly missing
# 3. no row + checkpoint + claim_orphan -> orphan, claim
existing = await thread_store.get(thread_id, user_id=None)
if existing is not None:
# Distinct detail string lets the frontend show "无权访问"
# instead of "不存在" for a cross-user / stale-session hit.
raise HTTPException(
status_code=404,
detail=f"Thread {thread_id} not accessible",
)
claimed = False
if claim_orphan and await _thread_checkpoint_exists(request, thread_id):
try:
await thread_store.create(thread_id, user_id=user_id)
logger.info(
"Claimed orphan thread %s for user %s (checkpoint present, no meta row)",
thread_id,
user_id,
)
claimed = True
except Exception:
logger.warning("Failed to claim orphan thread %s", thread_id, exc_info=True)
if not claimed:
raise HTTPException(
status_code=404,
detail=f"Thread {thread_id} not found",
)
return await func(*args, **kwargs)
@@ -21,7 +21,7 @@ from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer, get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
from app.gateway.services import sse_consumer, start_run
from app.gateway.services import immediate_end_sse, sse_consumer, start_run
from deerflow.runtime import RunRecord, serialize_channel_values
logger = logging.getLogger(__name__)
@@ -96,7 +96,7 @@ def _record_to_response(record: RunRecord) -> RunResponse:
@router.post("/{thread_id}/runs", response_model=RunResponse)
@require_permission("runs", "create", owner_check=True, require_existing=True)
@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True)
async def create_run(thread_id: str, body: RunCreateRequest, request: Request) -> RunResponse:
"""Create a background run (returns immediately)."""
record = await start_run(body, thread_id, request)
@@ -104,7 +104,7 @@ async def create_run(thread_id: str, body: RunCreateRequest, request: Request) -
@router.post("/{thread_id}/runs/stream")
@require_permission("runs", "create", owner_check=True, require_existing=True)
@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True)
async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -> StreamingResponse:
"""Create a run and stream events via SSE.
@@ -132,7 +132,7 @@ async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -
@router.post("/{thread_id}/runs/wait", response_model=dict)
@require_permission("runs", "create", owner_check=True, require_existing=True)
@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True)
async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> dict:
"""Create a run and block until it completes, returning the final state."""
record = await start_run(body, thread_id, request)
@@ -173,7 +173,11 @@ async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse:
run_mgr = get_run_manager(request)
record = run_mgr.get(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
# In-memory miss: fall back to the persistent store so a run still
# resolves after a Gateway restart instead of 404ing.
record = await run_mgr.get_persisted(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return _record_to_response(record)
@@ -222,11 +226,27 @@ async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingRe
bridge = get_stream_bridge(request)
run_mgr = get_run_manager(request)
record = run_mgr.get(run_id)
if record is None or record.thread_id != thread_id:
if record is not None and record.thread_id == thread_id:
return StreamingResponse(
sse_consumer(bridge, record, request, run_mgr),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# Not in memory: a run that still exists in the store but has no live task
# means the Gateway restarted (or the finished run was cleaned up). There
# is nothing to reconnect to, so stream a clean terminal end instead of a
# 404 — the SDK then clears its reconnect key and renders history.
persisted = await run_mgr.get_persisted(run_id)
if persisted is None or persisted.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return StreamingResponse(
sse_consumer(bridge, record, request, run_mgr),
immediate_end_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
@@ -255,7 +275,22 @@ async def stream_existing_run(
run_mgr = get_run_manager(request)
record = run_mgr.get(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
# In-memory miss. If the run survives in the store it's a dead record
# from before a restart (or already finished) — nothing to join or
# cancel, so emit a clean terminal end so the SDK stops reconnecting
# instead of 404ing. A truly unknown run still 404s.
persisted = await run_mgr.get_persisted(run_id)
if persisted is None or persisted.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return StreamingResponse(
immediate_end_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# Cancel if an action was requested (stop-button / interrupt flow)
if action is not None:
@@ -42,6 +42,20 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
async def immediate_end_sse():
"""Yield a single terminal ``end`` SSE frame and stop.
Used when a run exists in the persistent store but not in the in-memory
registry — typically after a Gateway restart, or once a finished run's
record has been cleaned up. There is no live stream to join, so instead of
returning 404 (which the SDK's ``joinStream`` surfaces as a hard error and
never clears its reconnect key for) we stream a clean end. The SDK treats
this as success, clears ``lg:stream:{threadId}``, and the conversation
falls back to history rendering — killing the repeating switch-back 404.
"""
yield format_sse("end", None)
def format_sse(event: str, data: Any, *, event_id: str | None = None) -> str:
"""Format a single SSE frame.
@@ -112,6 +112,52 @@ class RunManager:
"""Return a run record by ID, or ``None``."""
return self._runs.get(run_id)
def _record_from_store(self, data: dict) -> RunRecord:
"""Rebuild a :class:`RunRecord` from a persisted store row.
The reconstructed record has no live ``task``/``abort_event`` wired to a
running coroutine — it represents *history*, not an in-flight run.
"""
try:
status = RunStatus(data.get("status") or RunStatus.success.value)
except ValueError:
status = RunStatus.success
return RunRecord(
run_id=data["run_id"],
thread_id=data["thread_id"],
assistant_id=data.get("assistant_id"),
status=status,
# Disconnect mode is irrelevant for a store-only record (no task to
# cancel); ``continue_`` keeps sse_consumer's finally block a no-op.
on_disconnect=DisconnectMode.continue_,
multitask_strategy=data.get("multitask_strategy", "reject"),
metadata=data.get("metadata") or {},
kwargs=data.get("kwargs") or {},
created_at=data.get("created_at") or "",
updated_at=data.get("updated_at") or "",
error=data.get("error"),
)
async def get_persisted(self, run_id: str) -> RunRecord | None:
"""Load a run from the backing store (process-restart fallback).
The in-memory registry is wiped on every Gateway restart, but the
``RunStore`` row survives. When :meth:`get` misses, callers fall back
here so a stale ``joinStream``/``GET run`` can report the run's final
status instead of 404ing. Returns ``None`` when there is no store, no
matching row, or the lookup fails (e.g. no user context).
"""
if self._store is None:
return None
try:
data = await self._store.get(run_id)
except Exception:
logger.warning("Failed to load run %s from store", run_id, exc_info=True)
return None
if not data:
return None
return self._record_from_store(data)
async def list_by_thread(self, thread_id: str) -> list[RunRecord]:
"""Return all runs for a given thread, newest first."""
async with self._lock: