From e7b51a30a3f5a8564dda52b821a5fdb02ff7b8c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 11 Jun 2026 21:25:36 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20=E6=99=BA=E8=83=BD=E4=BD=93=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=80=A7=E5=AF=B9=E9=BD=90=E2=80=94=E2=80=94=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=8F=AF=E8=A7=81=E5=8D=B3=E5=8F=AF=E6=89=93=E5=BC=80?= =?UTF-8?q?=E9=97=AE=E7=AD=94=EF=BC=8C=E6=B6=88=E9=99=A4=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_visible 此前比列表过滤更严格(仅 内置/本人/已发布),导致私密广场 智能体(转私密强制 published=false)和「我的」里已收藏但被取消发布的 智能体能在列表看到、点开问答却 404(前端连带报 X.map is not a function)。 - AgentRepository._visible_filter / MemoryAgentStore._is_fetchable: 单条读取放宽为所有列表口径的并集(+is_private 豁免、+本人收藏豁免) - 详情接口与 _enforce_agent_access 运行门禁:管理员回退 get_any, 对齐 ?admin=true 全量列表 - 普通未发布草稿对陌生人仍不可见,隐私不放开 - 本地新增 tests/test_agent_visibility.py 覆盖 SQL/Memory 双实现 (tests/ 目录在本仓库被 gitignore,不随提交) Co-Authored-By: Claude Fable 5 --- offline-backend-20260512/backend/CLAUDE.md | 4 ++- .../backend/app/gateway/routers/agents.py | 4 +++ .../backend/app/gateway/services.py | 10 ++++++-- .../deerflow/persistence/agents/memory.py | 11 +++++++- .../deerflow/persistence/agents/sql.py | 25 +++++++++++++++---- 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index fd7020fa..595b68c1 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -306,7 +306,9 @@ Self-contained product knowledge base. **All knowledge capability lives in this ### Custom Agent Persistence -Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. +Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are *listed* only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. + +**Visibility invariant: list-visible ⇒ openable.** Single-record reads (`AgentStore.get_visible`, which gates `GET /api/agents/{id}` *and* run creation via `_enforce_agent_access`) are deliberately **more lenient** than the listing filters — they must cover the union of every listing surface so an agent that shows up in any list never 404s when opened or chatted with: built-in / owned / published (baseline), **`is_private=true`** (the private-square password is the *discovery* gate; reads are not re-gated — necessary because moving an agent into the private square forces `published=false`), and **favorited by the requesting user** (the "我的" listing keeps favorites after the owner unpublishes). Admins additionally fall back to `get_any` in the detail route and run gate, matching the all-agents admin listing. Plain unpublished agents stay hidden from strangers. Predicates: `AgentRepository._visible_filter` / `MemoryAgentStore._is_fetchable`. Tests: `tests/test_agent_visibility.py`. **Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's 发布者 — built-in agents never match), powering the gallery's click-发布者-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`. diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 75d8f9cd..9e4ffd72 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -723,6 +723,10 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse: await _maybe_sync_legacy_agents(store) user_id = _current_user_id(request) record = await store.get_visible(agent_id, user_id) + if record is None and _is_admin_user(request): + # The admin console lists every agent (?admin=true), so an admin + # must be able to open any of them — mirror that here. + record = await store.get_any(agent_id) if record is None: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") # Detail view legitimately reads SOUL.md + config.yaml — hop to a diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 46625e28..c5c2baec 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -477,6 +477,9 @@ async def _enforce_agent_access(body: Any, request: Request) -> None: store = get_agent_store(request) await _maybe_sync_legacy_agent_records(store) user_id = get_effective_user_id() + # Admins see every agent in the admin console listing (?admin=true), so + # they may also start a run against any of them. + is_admin = getattr(getattr(request.state, "user", None), "system_role", None) == "admin" for agent_id in agent_ids: try: normalized = validate_agent_id(agent_id) @@ -484,8 +487,11 @@ async def _enforce_agent_access(body: Any, request: Request) -> None: raise HTTPException(status_code=422, detail=str(exc)) from exc if normalized is None: continue - if await store.get_visible(normalized, user_id) is None: - raise HTTPException(status_code=404, detail=f"Agent {normalized} not found") + if await store.get_visible(normalized, user_id) is not None: + continue + if is_admin and await store.get_any(normalized) is not None: + continue + raise HTTPException(status_code=404, detail=f"Agent {normalized} not found") # --------------------------------------------------------------------------- diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py index 002653a0..51003e8b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py @@ -32,6 +32,15 @@ class MemoryAgentStore(AgentStore): def _is_visible(agent: dict[str, Any], user_id: str) -> bool: return agent.get("user_id") is None or agent.get("user_id") == user_id or bool(agent.get("published")) + def _is_fetchable(self, agent: dict[str, Any], user_id: str) -> bool: + """Single-record visibility (``get_visible``) — the union of every + listing surface, so a list-visible agent always opens. Mirrors + ``AgentRepository._visible_filter``: baseline visibility OR + private-square (password is the discovery gate, reads are not + re-gated) OR favorited (the "我的" listing keeps favorites even after + the owner unpublishes).""" + return self._is_visible(agent, user_id) or bool(agent.get("is_private")) or (user_id, agent["id"]) in self._favorites + async def list_visible(self, user_id: str) -> list[dict[str, Any]]: rows = [_copy(agent) for agent in self._agents.values() if self._is_visible(agent, user_id)] rows.sort(key=lambda agent: agent.get("created_at") or "") @@ -39,7 +48,7 @@ class MemoryAgentStore(AgentStore): async def get_visible(self, agent_id: str, user_id: str) -> dict[str, Any] | None: agent = self._agents.get(agent_id) - if agent is None or not self._is_visible(agent, user_id): + if agent is None or not self._is_fetchable(agent, user_id): return None return _copy(agent) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index 1e7564e3..2e662401 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -40,12 +40,27 @@ class AgentRepository(AgentStore): def _visible_filter(user_id: str): """Visibility for single-record fetches (``get_visible``). - Lenient: a caller who already knows the agent id can fetch it even - when it's flagged ``is_private``. The private-square password is the - *discovery* gate (listings); individual reads are not re-gated, so a - bookmarked / linked private agent stays openable. + Lenient — must cover the union of every listing surface, so any agent + a user can see in a list always opens (detail + chat) instead of + 404ing: + + - built-in / owned / published — the baseline gallery visibility; + - ``is_private`` — the private-square password is the *discovery* + gate (listings); individual reads are not re-gated, so a private + agent opened from the square (or a bookmark/link) stays openable. + Note moving an agent into the private square forces + ``published=false``, so this clause cannot lean on ``published``; + - favorited — the "我的" listing keeps favorited agents even after + the owner unpublishes them, so reads keep honoring the favorite. """ - return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True)) + fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id) + return or_( + AgentRow.user_id.is_(None), + AgentRow.user_id == user_id, + AgentRow.published.is_(True), + AgentRow.is_private.is_(True), + AgentRow.id.in_(fav_subq), + ) @staticmethod def _listing_filter(user_id: str): From 1b86373a5490d78c63231762b4b07cc246157769 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 11 Jun 2026 23:18:58 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E6=A0=87=E7=AD=BE/=E5=AE=9E=E4=BD=93=E5=85=B3=E8=81=94?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC=20+=20=E4=BC=9A=E8=AF=9D=E9=87=8D=E8=BF=9E?= =?UTF-8?q?=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: - Wiki 标签与实体改为可点击,弹出 RelatedNotesDialog 查看关联知识 - 知识库首次进入自动打开最近笔记,避免空白阅读区 - 流式重连识别 "Run not found",清理 sessionStorage 残留 key 并静默回退历史 后端: - 线程鉴权区分 not found / not accessible,并支持 DB 切换产生的孤儿线程认领 - run 查询/join 在内存未命中时回退持久化存储,重启后返回干净的终止 SSE 而非 404 --- .../components/wiki/RelatedNotesDialog.tsx | 110 ++++++++++++++++++ .../src/components/wiki/WikiContent.tsx | 41 +++++-- frontend-web/src/core/threads/hooks.ts | 33 +++++- frontend-web/src/pages/KnowledgeBasePage.tsx | 11 ++ .../backend/app/gateway/authz.py | 74 ++++++++++-- .../app/gateway/routers/thread_runs.py | 51 ++++++-- .../backend/app/gateway/services.py | 14 +++ .../harness/deerflow/runtime/runs/manager.py | 46 ++++++++ 8 files changed, 357 insertions(+), 23 deletions(-) create mode 100644 frontend-web/src/components/wiki/RelatedNotesDialog.tsx diff --git a/frontend-web/src/components/wiki/RelatedNotesDialog.tsx b/frontend-web/src/components/wiki/RelatedNotesDialog.tsx new file mode 100644 index 00000000..1cc5e048 --- /dev/null +++ b/frontend-web/src/components/wiki/RelatedNotesDialog.tsx @@ -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 ( + + + + + {filter?.kind === "entity" ? ( + + ) : ( + + )} + {filter?.value} + + + {filter?.kind === "entity" + ? "提到该实体的关联知识" + : "使用该标签的关联知识"} + + + + + {isLoading || (isFetching && items.length === 0) ? ( +
+ 加载中... +
+ ) : items.length === 0 ? ( +
+ 暂无其他关联知识。 +
+ ) : ( +
    + {items.map((note) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend-web/src/components/wiki/WikiContent.tsx b/frontend-web/src/components/wiki/WikiContent.tsx index 38d4705f..354f190b 100644 --- a/frontend-web/src/components/wiki/WikiContent.tsx +++ b/frontend-web/src/components/wiki/WikiContent.tsx @@ -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(note.status); const [activeHeadingId, setActiveHeadingId] = useState(null); + // Tag / entity chip clicked → show notes that share it. + const [relatedFilter, setRelatedFilter] = useState( + 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({ 创建:{formatDateTime(note.created_at).slice(0, 10)} ) : null} {note.tags.map((tag) => ( - 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} - + ))} @@ -287,11 +298,15 @@ export function WikiContent({ {note.entities.map((ent) => { const meta = entityMeta(ent.entity_type); return ( - + 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({ {meta.label} - + ); })} @@ -373,6 +388,18 @@ export function WikiContent({ + + { + if (!open) setRelatedFilter(null); + }} + onSelect={(id) => { + setRelatedFilter(null); + navigate(`/page/workspace/knowledge/${id}`); + }} + /> ); } diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index caea58c4..79a1273b 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -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 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 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. diff --git a/frontend-web/src/pages/KnowledgeBasePage.tsx b/frontend-web/src/pages/KnowledgeBasePage.tsx index 7ce56d67..b12cfb5f 100644 --- a/frontend-web/src/pages/KnowledgeBasePage.tsx +++ b/frontend-web/src/pages/KnowledgeBasePage.tsx @@ -468,6 +468,8 @@ export default function KnowledgeBasePage() { const [status, setStatus] = useState("all"); const [expandedIds, setExpandedIds] = useState>(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; diff --git a/offline-backend-20260512/backend/app/gateway/authz.py b/offline-backend-20260512/backend/app/gateway/authz.py index 89fd943c..0d8d2899 100644 --- a/offline-backend-20260512/backend/app/gateway/authz.py +++ b/offline-backend-20260512/backend/app/gateway/authz.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py b/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py index 38129fa5..1f108dcc 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py @@ -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: diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 46625e28..3f0e8c37 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -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. diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py index 9bd39c06..64deed00 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py @@ -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: From 28098b315558667635f68e663d43d07cf3d4497d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Fri, 12 Jun 2026 11:09:32 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E5=AE=9E=E4=BD=93=E7=AD=9B=E9=80=89=E3=80=81=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E6=94=B6=E7=B4=A7=E3=80=81=E5=B5=8C?= =?UTF-8?q?=E5=85=A5=E4=B8=BB=E9=A2=98=E4=B8=8E=E6=8F=90=E9=97=AE=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 知识库笔记列表支持按实体精确筛选(路由/服务/仓储层透传,关系图索引查询) - 自定义智能体技能白名单在 skill_view 与 skill_manage 的 list/view 别名上同样生效; config 未声明 skills 时视为无可见技能 - 嵌入圆桌主题解析默认深蓝,强制主题期间不再写 localStorage 覆盖主站选择 - 智能体提问建议拆出 agent-question-chips 组件,编辑器与详情面板联动优化 - 新增 qa-client 问答测试脚本 Co-Authored-By: Claude Fable 5 --- frontend-web/src/App.tsx | 26 +++- .../components/wiki/RelatedNotesDialog.tsx | 100 +++++++++++-- .../src/components/wiki/WikiHeader.tsx | 13 +- .../workspace/agents/agent-detail-panel.tsx | 41 ++++-- .../workspace/agents/agent-question-chips.tsx | 78 ++++++++++ .../agents/agent-question-editor.tsx | 33 ++++- .../settings/skill-settings-page.tsx | 13 ++ frontend-web/src/contexts/ThemeContext.tsx | 16 ++- frontend-web/src/core/embed/params.ts | 11 ++ frontend-web/src/core/knowledge/api.ts | 1 + frontend-web/src/core/knowledge/types.ts | 2 + frontend-web/src/pages/AgentChatPage.tsx | 31 +--- frontend-web/src/pages/EmbedChatPage.tsx | 15 +- .../src/pages/EmbedRoundtablePage.tsx | 26 +--- frontend-web/src/pages/NewAgentPage.tsx | 8 +- offline-backend-20260512/backend/CLAUDE.md | 3 +- .../backend/app/gateway/routers/knowledge.py | 3 +- .../harness/deerflow/knowledge/repository.py | 11 ++ .../harness/deerflow/knowledge/service.py | 2 + .../deerflow/tools/builtins/skill_tools.py | 48 +++++-- .../deerflow/tools/skill_manage_tool.py | 10 +- qa-client/qa_client.py | 135 ++++++++++++++++++ 22 files changed, 500 insertions(+), 126 deletions(-) create mode 100644 frontend-web/src/components/workspace/agents/agent-question-chips.tsx create mode 100644 qa-client/qa_client.py diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 277f3974..23999e96 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -9,6 +9,11 @@ import { I18nProvider } from "@/core/i18n/context"; import { detectLocale } from "@/core/i18n/locale"; import { ChatProvider } from "@/contexts/ChatContext"; import { ThemeProvider } from "@/contexts/ThemeContext"; +import { + resolveEmbedRoundtableTheme, + resolveEmbedTheme, +} from "@/core/embed/params"; +import { readWujieProps } from "@/core/embed/wujie"; import { HashRouter, useLocation } from "react-router-dom"; @@ -29,7 +34,26 @@ import { NotFoundPage } from "./pages/NotFoundPage"; function AppProviders({ children }: { children: React.ReactNode }) { const locale = useMemo(() => detectLocale(), []); const location = useLocation(); - const forcedTheme = location.pathname === "/landing" ? "light" : undefined; + // 嵌入路由的主题用 forcedTheme 强制:只影响当前渲染,不写 localStorage。 + // 千万不要在嵌入页里调 ThemeContext.setTheme —— 它会持久化主题并打上 + // 「用户已显式选择」标记。无界子应用与主站同域共享 localStorage,宿主 + // 注入的 theme 会就此覆盖用户在主站自己选的主题(且每次打开宿主都覆盖)。 + const forcedTheme = useMemo(() => { + if (location.pathname === "/landing") return "light"; + if (location.pathname === "/embed/roundtable") { + const wp = readWujieProps(); + const raw = + (typeof wp.theme === "string" && wp.theme.trim()) || + new URLSearchParams(location.search).get("theme")?.trim() || + "dark"; + return resolveEmbedRoundtableTheme(raw); + } + if (location.pathname.startsWith("/embed/chats")) { + const raw = new URLSearchParams(location.search).get("theme")?.trim(); + return raw ? resolveEmbedTheme(raw) : undefined; + } + return undefined; + }, [location.pathname, location.search]); return ( (value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = window.setTimeout(() => setDebounced(value), delay); + return () => window.clearTimeout(id); + }, [value, delay]); + return debounced; +} + /** * 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). + * Server-side keyword search + pagination so huge graphs stay usable: + * tags use the backend's exact `tag` filter, entities the exact `entity` + * filter (note→entity relations), both combinable with `keyword`. */ export function RelatedNotesDialog({ filter, @@ -34,17 +57,33 @@ export function RelatedNotesDialog({ 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 [search, setSearch] = useState(""); + const debouncedSearch = useDebounced(search.trim()); + const [page, setPage] = useState(0); + + // New chip or new search term → back to the first page. + useEffect(() => { + setPage(0); + }, [filter?.kind, filter?.value, debouncedSearch]); + // Reset the search box whenever a different chip is opened. + useEffect(() => { + setSearch(""); + }, [filter?.kind, filter?.value]); + + const params = { + ...(filter?.kind === "tag" ? { tag: filter.value } : {}), + ...(filter?.kind === "entity" ? { entity: filter.value } : {}), + keyword: debouncedSearch || undefined, + limit: filter ? PAGE_SIZE : 0, + offset: page * PAGE_SIZE, + }; const { data, isLoading, isFetching } = useKnowledgeNotes(params); const items = (data?.items ?? []).filter( (n: KnowledgeNote) => n.id !== currentNoteId, ); + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); return ( @@ -62,17 +101,28 @@ export function RelatedNotesDialog({ {filter?.kind === "entity" ? "提到该实体的关联知识" : "使用该标签的关联知识"} + {total > 0 ? ` · 共 ${total} 条` : ""} - +
+ + setSearch(e.target.value)} + placeholder="在关联知识中搜索标题/内容..." + className="h-9 pl-8" + /> +
+ + {isLoading || (isFetching && items.length === 0) ? (
加载中...
) : items.length === 0 ? (
- 暂无其他关联知识。 + {debouncedSearch ? "没有匹配的关联知识。" : "暂无其他关联知识。"}
) : (
    @@ -104,6 +154,32 @@ export function RelatedNotesDialog({
)}
+ + {pageCount > 1 ? ( +
+ + 第 {page + 1} / {pageCount} 页 + +
+ + +
+
+ ) : null}
); diff --git a/frontend-web/src/components/wiki/WikiHeader.tsx b/frontend-web/src/components/wiki/WikiHeader.tsx index a82c391d..b278ad49 100644 --- a/frontend-web/src/components/wiki/WikiHeader.tsx +++ b/frontend-web/src/components/wiki/WikiHeader.tsx @@ -1,5 +1,4 @@ import { - BookOpen, Copy, Menu, Network, @@ -41,7 +40,7 @@ function HeaderButton({ ); } -/** Light 56px wiki header: logo + product name on the left, action entries on the right. */ +/** Light 56px wiki header: plain page title on the left, action entries on the right. */ export function WikiHeader({ onToggleSidebar, onImportFile, @@ -68,14 +67,8 @@ export function WikiHeader({ > -
- -
-
-
- 问答梳理 -
-
LLM Wiki · 问答梳理中心
+
+ 问答梳理
diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index 39c9d292..c11946c0 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -422,14 +422,7 @@ export function AgentDetailPanel({ ) : (
- @@ -591,14 +584,29 @@ export function AgentDetailPanel({ )} {/* ── 常用问题 ───────────────────────────────────────────────────── */} - {(editing || questions.length > 0) && ( + {(editing || questions.length > 0 || canEdit) && (
} title="常用问题" extra={ - - {(editing && draft ? draft.questions : questions).length} 个 - +
+ + {(editing && draft ? draft.questions : questions).length} 个 + + {!editing && canEdit && ( + + + + )} +
} > {editing && draft ? ( @@ -607,6 +615,15 @@ export function AgentDetailPanel({ onChange={(next) => setDraft({ ...draft, questions: next })} disabled={isSaving} /> + ) : questions.length === 0 ? ( + ) : (
{questions.map((question, index) => { diff --git a/frontend-web/src/components/workspace/agents/agent-question-chips.tsx b/frontend-web/src/components/workspace/agents/agent-question-chips.tsx new file mode 100644 index 00000000..69bbb823 --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-question-chips.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { useState } from "react"; + +import type { AgentQuestion } from "@/core/agents"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +/** Chips shown before the rest folds behind the 更多问题 toggle. */ +const VISIBLE_COUNT = 4; + +/** + * 常用问题 prompt chips on the agent chat landing screen — light-gray + * borderless pills stacked left-aligned between the welcome hero and the + * input box. Clicking a chip sends the question's full prompt through the + * page's existing submit flow. Lists longer than {@link VISIBLE_COUNT} fold + * behind a 更多问题 toggle; an empty list renders nothing. + */ +export function AgentQuestionChips({ + questions, + onAsk, + disabled, + className, +}: { + questions: AgentQuestion[]; + onAsk: (prompt: string) => void; + disabled?: boolean; + className?: string; +}) { + const [expanded, setExpanded] = useState(false); + + if (questions.length === 0) return null; + + const visible = expanded ? questions : questions.slice(0, VISIBLE_COUNT); + const foldedCount = questions.length - VISIBLE_COUNT; + + return ( +
+ {visible.map((question, index) => ( + + ))} + {foldedCount > 0 && ( + + )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/agents/agent-question-editor.tsx b/frontend-web/src/components/workspace/agents/agent-question-editor.tsx index d0f2e223..489744b4 100644 --- a/frontend-web/src/components/workspace/agents/agent-question-editor.tsx +++ b/frontend-web/src/components/workspace/agents/agent-question-editor.tsx @@ -12,10 +12,20 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import type { AgentQuestion } from "@/core/agents"; +/** Soft cap on the chip label length — beyond this the landing chip truncates. */ +const TITLE_SOFT_LIMIT = 80; + +/** The text a question row would actually persist as (panel save falls back + * title↔prompt), used for duplicate detection. */ +function effectivePrompt(q: AgentQuestion): string { + return (q.prompt.trim() || q.title.trim()).toLowerCase(); +} + /** * Editable list of an agent's 常用问题 (recommended questions). Each row has a * short `title` (the chip label) and the full `prompt` sent on click. Supports - * add / remove / inline edit / reorder (move up & down). Pure controlled + * add / remove / inline edit / reorder (move up & down), with non-blocking + * hints for over-long titles and duplicate questions. Pure controlled * component — persistence stays with the caller (edit dialog or detail panel). */ export function AgentQuestionEditor({ @@ -52,7 +62,13 @@ export function AgentQuestionEditor({ 尚未配置常用问题,点击下方按钮添加。

)} - {value.map((item, index) => ( + {value.map((item, index) => { + const titleLength = item.title.trim().length; + const prompt = effectivePrompt(item); + const duplicateOf = prompt + ? value.findIndex((other, i) => i < index && effectivePrompt(other) === prompt) + : -1; + return (
update(index, { prompt: e.currentTarget.value })} className="min-h-16 resize-none text-sm" /> + {titleLength > TITLE_SOFT_LIMIT && ( +

+ 标题建议不超过 {TITLE_SOFT_LIMIT} 字(当前 {titleLength} 字),过长会在聊天区被截断。 +

+ )} + {duplicateOf >= 0 && ( +

+ 与第 {duplicateOf + 1} 条问题内容重复。 +

+ )}
- ))} + ); + })} +
= ({ children }) => { - const { resolvedTheme, setTheme: setNextTheme } = useNextTheme(); + // forcedTheme:嵌入路由 / landing 由 AppProviders 强制的主题。强制期间 + // 它决定渲染,且本组件不得写 localStorage(嵌入子应用与主站同域共享 + // 存储,写入会覆盖用户在主站的选择)。 + const { resolvedTheme, forcedTheme, setTheme: setNextTheme } = useNextTheme(); const [mounted, setMounted] = useState(false); // Admin-configured global default. If the user hasn't explicitly chosen // a theme yet, this value drives the rendered theme — and re-syncs on @@ -144,7 +147,7 @@ export const ThemeProvider: React.FC = ({ children }) => { // 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作 // 「用户已选过」,admin 默认就再也注入不进来了。 useEffect(() => { - if (!mounted) return; + if (!mounted || forcedTheme) return; const current = localStorage.getItem(STORAGE_KEY); const legacy = localStorage.getItem('theme'); @@ -162,24 +165,25 @@ export const ThemeProvider: React.FC = ({ children }) => { if (legacy) { localStorage.removeItem('theme'); } - }, [mounted, setNextTheme]); + }, [mounted, forcedTheme, setNextTheme]); // Boot-time sync: when the user has never picked a theme themselves, // apply whatever the admin configured. Re-runs whenever the admin // setting changes (so a logged-in user sees the new default after // refreshing without having to clear cookies). useEffect(() => { - if (!mounted) return; + if (!mounted || forcedTheme) return; if (hasExplicitChoice()) return; const adminDefault = appearance?.default_theme; if (!adminDefault) return; if (resolvedTheme !== adminDefault) { setNextTheme(adminDefault); } - }, [mounted, appearance?.default_theme, resolvedTheme, setNextTheme]); + }, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]); + const effectiveTheme = forcedTheme ?? resolvedTheme; const theme: ThemeType = mounted - ? resolvedTheme === 'dark' + ? effectiveTheme === 'dark' ? 'dark' : 'light' : readPersistedTheme(); diff --git a/frontend-web/src/core/embed/params.ts b/frontend-web/src/core/embed/params.ts index 677ae364..be4fb8e0 100644 --- a/frontend-web/src/core/embed/params.ts +++ b/frontend-web/src/core/embed/params.ts @@ -44,3 +44,14 @@ export function resolveEmbedChatParams(search: string): EmbedChatParams { export function resolveEmbedTheme(themeParam: string): "dark" | "light" { return resolveThemeFromQuery(themeParam || null); } + +/** + * 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**: + * 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。 + * 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。 + */ +export function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" { + const v = raw.trim().toLowerCase(); + if (v === "light" || v === "red" || v === "红白") return "light"; + return "dark"; +} diff --git a/frontend-web/src/core/knowledge/api.ts b/frontend-web/src/core/knowledge/api.ts index 3323bd5d..182f34f5 100644 --- a/frontend-web/src/core/knowledge/api.ts +++ b/frontend-web/src/core/knowledge/api.ts @@ -32,6 +32,7 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) { const qs = new URLSearchParams(); if (params.keyword) qs.set("keyword", params.keyword); if (params.tag) qs.set("tag", params.tag); + if (params.entity) qs.set("entity", params.entity); if (params.source_type) qs.set("source_type", params.source_type); if (params.status) qs.set("status", params.status); qs.set("limit", String(params.limit ?? 20)); diff --git a/frontend-web/src/core/knowledge/types.ts b/frontend-web/src/core/knowledge/types.ts index 676b7ab2..7699dae4 100644 --- a/frontend-web/src/core/knowledge/types.ts +++ b/frontend-web/src/core/knowledge/types.ts @@ -54,6 +54,8 @@ export interface KnowledgeListResponse { export interface KnowledgeListParams { keyword?: string; tag?: string; + /** Exact entity-name filter (matched via the knowledge relation graph). */ + entity?: string; source_type?: string; status?: string; limit?: number; diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 8d243775..91eb96a8 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel"; +import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips"; import { AgentWelcome } from "@/components/workspace/agent-welcome"; import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ChatBox, useThreadChat } from "@/components/workspace/chats"; @@ -28,7 +29,6 @@ import { useThreadStream } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; -import { cn } from "@/lib/utils"; export default function AgentChatPage() { const routeBase = useRouteBase(); @@ -235,29 +235,12 @@ export default function AgentChatPage() { className="mb-7" />
- {commonQuestions.length > 0 && ( -
- {commonQuestions.map((question, index) => ( - - ))} -
- )} + handleSubmit({ text, files: [] })} + />
(); const searchParams = useSearchParams(); - const { setTheme } = useTheme(); + // 主题不在这里处理 —— ?theme= 由 AppProviders 通过 forcedTheme 强制 + // (不落 localStorage),避免污染主站用户自己选的主题。 const embedParams = resolveEmbedChatParams(searchParams.toString()); const [authReady, setAuthReady] = useState(false); const [authError, setAuthError] = useState(null); @@ -51,12 +48,6 @@ export default function EmbedChatPage() { const [settings] = useThreadSettings(threadId); const { tokenUsageEnabled } = useModels(); - useEffect(() => { - if (embedParams.theme) { - setTheme(resolveEmbedTheme(embedParams.theme)); - } - }, [embedParams.theme, setTheme]); - useEffect(() => { let cancelled = false; async function runLogin() { diff --git a/frontend-web/src/pages/EmbedRoundtablePage.tsx b/frontend-web/src/pages/EmbedRoundtablePage.tsx index 2d626389..77851c9d 100644 --- a/frontend-web/src/pages/EmbedRoundtablePage.tsx +++ b/frontend-web/src/pages/EmbedRoundtablePage.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; -import { useTheme } from "@/contexts/ThemeContext"; import { loginByUsername } from "@/core/auth/api"; import { setStoredAuth } from "@/core/auth"; import { readWujieProps, isWujieEnv } from "@/core/embed/wujie"; @@ -13,17 +12,6 @@ import "@/roundtable-planning/styles/roundtable-planning.css"; const DEFAULT_USERNAME = "guest"; -/** - * 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**: - * 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。 - * 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。 - */ -function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" { - const v = raw.trim().toLowerCase(); - if (v === "light" || v === "red" || v === "红白") return "light"; - return "dark"; -} - /** * 圆桌规划嵌入页(独立入口,**与现有 wujie ticket 登录无关**)。 * @@ -37,9 +25,10 @@ function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" { */ export default function EmbedRoundtablePage() { const searchParams = useSearchParams(); - const { setTheme } = useTheme(); - // username / password / theme:wujie props 优先,URL query 兜底。 + // username / password:wujie props 优先,URL query 兜底。 + // 主题不在这里处理 —— 嵌入路由的主题由 AppProviders 通过 forcedTheme + // 强制(不落 localStorage),避免污染主站用户自己选的主题。 const wp = readWujieProps(); const username = (typeof wp.username === "string" && wp.username.trim()) || @@ -53,19 +42,10 @@ export default function EmbedRoundtablePage() { (typeof wp.password === "string" && wp.password) || searchParams.get("password") || undefined; - // 主题色:宿主传入优先;默认「深蓝」= dark 主题(其主色为蓝,oklch hue 259)。 - const themeParam = - (typeof wp.theme === "string" && wp.theme.trim()) || - searchParams.get("theme")?.trim() || - "dark"; const [authReady, setAuthReady] = useState(false); const [authError, setAuthError] = useState(null); - useEffect(() => { - setTheme(resolveEmbedRoundtableTheme(themeParam)); - }, [themeParam, setTheme]); - useEffect(() => { if (!isWujieEnv()) return; document.documentElement.classList.add("wujie-embed"); diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 29cb8756..822738e6 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -184,13 +184,11 @@ export default function NewAgentPage() { // Name is intentionally NOT required here so the user can start chatting // immediately. If they leave it blank, ensureAgent() generates a placeholder // and writes it back into the input so they can rename it later. - if (enabledSkills.length > 0 && selectedSkills.length === 0) { - setFormError("请至少选择一个技能"); - return null; - } + // Zero skills is a valid choice: the backend treats an empty/omitted skill + // list as "no skills" for custom agents (never "all skills"). setFormError(""); return nameInput.trim(); - }, [enabledSkills.length, nameInput, selectedSkills.length]); + }, [nameInput]); const ensureAgent = useCallback(async () => { const provided = validateForm(); diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 595b68c1..4ee79f54 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -251,7 +251,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. | | **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. | | **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). | -| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). | +| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/entity/source_type/status + pagination; `entity` is an exact entity-name filter resolved via the note→entity relation graph, combinable with `keyword` — powers the related-notes dialog's server-side search + paging); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). | Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway. @@ -406,6 +406,7 @@ Operators upgrading an existing DB run the whole file (`USE ;` then execute) - **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools) - **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json - **Injection**: Enabled skills listed in agent system prompt with container paths +- **Per-agent whitelist (custom agents)**: a custom agent's `config.yaml` `skills:` field is an explicit allowlist — `None` (key omitted) and `[]` both mean **no skills**, never "all skills". Only the default lead agent (no `agent_id`) and the bootstrap flow see every enabled skill. Enforced consistently in three places: the system prompt (`get_skills_prompt_section`, empty whitelist → no `` block at all), the `skill_list` tool, and the `skill_view` tool (out-of-whitelist names answer "not found" so existence isn't leaked) — including the `skill_manage` `list`/`view` aliases, which delegate to the shared `skill_list_impl`/`skill_view_impl` with the active `agent_id`. Tests: `tests/test_skill_tools_whitelist.py` - **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory - **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) — never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill → owner or admin; built-in/public → admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (一键补充: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` — an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) — distinct from the model returning an unusable name (→ `None`). The single endpoint maps that to `502 大模型报错,无法补全技能中文名:…`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N 个因大模型报错失败". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI 生成" plus an **admin-only** header "一键补充中文名" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action. diff --git a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py index 2c92ffe1..8562dde9 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py +++ b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py @@ -424,6 +424,7 @@ async def list_notes( request: Request, keyword: str | None = None, tag: str | None = None, + entity: str | None = None, source_type: str | None = None, status: str | None = None, limit: int = 20, @@ -432,7 +433,7 @@ async def list_notes( service = get_knowledge_service(request) limit = max(1, min(limit, 100)) offset = max(0, offset) - return await service.list_notes(keyword=keyword, tag=tag, source_type=source_type, status=status, limit=limit, offset=offset) + return await service.list_notes(keyword=keyword, tag=tag, entity=entity, source_type=source_type, status=status, limit=limit, offset=offset) @router.get("/notes/{note_id}") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/repository.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/repository.py index 436489b4..2cbe8f27 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/repository.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/repository.py @@ -276,6 +276,7 @@ class KnowledgeRepository: *, keyword: str | None = None, tag: str | None = None, + entity: str | None = None, source_type: str | None = None, status: str | None = None, include_archived: bool = False, @@ -303,6 +304,16 @@ class KnowledgeRepository: .where(func.lower(KnowledgeTagRow.name) == tag.lower()) ) conditions.append(KnowledgeNoteRow.id.in_(tag_subq)) + if entity: + # Exact entity match via the note→entity relation graph — precise + # and index-backed, unlike a content LIKE scan on large vaults. + entity_subq = ( + select(KnowledgeRelationRow.from_note_id) + .join(KnowledgeEntityRow, KnowledgeRelationRow.to_entity_id == KnowledgeEntityRow.id) + .where(func.lower(KnowledgeEntityRow.name) == entity.lower()) + .where(KnowledgeRelationRow.from_note_id.is_not(None)) + ) + conditions.append(KnowledgeNoteRow.id.in_(entity_subq)) for cond in conditions: stmt = stmt.where(cond) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py index 38e92dad..b83ca128 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py @@ -523,6 +523,7 @@ class KnowledgeService: *, keyword: str | None = None, tag: str | None = None, + entity: str | None = None, source_type: str | None = None, status: str | None = None, limit: int = 20, @@ -531,6 +532,7 @@ class KnowledgeService: items, total = await self.repo.list_notes( keyword=keyword, tag=tag, + entity=entity, source_type=normalize_source_type(source_type) if source_type else None, status=status, limit=limit, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py index d2722103..50fae12a 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py @@ -39,10 +39,15 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None: """Return the explicit skill name set declared in an agent's config.yaml. Semantics: - - ``None`` returned → no agent_id, or config missing — caller should - fall back to "all skills visible". - - ``set()`` (empty) → agent has ``skills: []`` declared — visibility - is intentionally empty. + - ``None`` returned → no agent_id, or the agent's on-disk config is + missing entirely — caller should fall back to "all skills visible" + (default lead agent behavior). + - ``set()`` (empty) → custom agent with ``skills: []`` declared **or** + with no ``skills`` key at all — visibility is intentionally empty. + This mirrors the prompt layer (``agents_config.AgentConfig.skills``: + ``None``/``[]`` both mean "no skills" for custom agents), so an agent + whose config omits ``skills`` can no longer enumerate every skill + through this tool. - Non-empty set → only these names are visible to this agent. """ if not agent_id: @@ -51,9 +56,9 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None: from deerflow.config.agents_config import load_agent_config cfg = load_agent_config(agent_id) - if cfg is None or cfg.skills is None: + if cfg is None: return None - return set(cfg.skills) + return set(cfg.skills or []) except Exception: logger.debug("Failed to load agent config for skill_list filter", exc_info=True) return None @@ -119,16 +124,21 @@ def _load_owned_skill_names(user_id: str | None) -> set[str] | None: return None -@tool("skill_view") -def skill_view_tool(name: str) -> str: - """Read the full SKILL.md content of a skill by name. +def skill_view_impl(name: str, agent_id: str | None) -> str: + """Read a skill's SKILL.md, honoring the agent's config.yaml whitelist. - Args: - name: The skill name (hyphen-case, e.g. 'my-skill'). + Custom agents may only read skills on their whitelist — same filter as + ``skill_list``, so a restricted agent cannot bypass its allowlist by + guessing skill names. Replies "not found" rather than "forbidden" to + avoid leaking which skills exist. """ from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.usage import bump_view + allowed = _load_agent_skill_whitelist(agent_id) + if allowed is not None and name not in allowed: + return f"Skill '{name}' not found." + storage = get_or_new_skill_storage() try: if storage.custom_skill_exists(name): @@ -156,6 +166,16 @@ def skill_view_tool(name: str) -> str: return content +@tool("skill_view") +def skill_view_tool(name: str, runtime: ToolRuntime) -> str: + """Read the full SKILL.md content of a skill by name. + + Args: + name: The skill name (hyphen-case, e.g. 'my-skill'). + """ + return skill_view_impl(name, _resolve_active_agent_id(runtime)) + + @tool("skill_list") def skill_list_tool(runtime: ToolRuntime) -> str: """List skills available to this agent, with metadata and usage stats. @@ -175,11 +195,15 @@ def skill_list_tool(runtime: ToolRuntime) -> str: state, use_count, view_count, patch_count, last_activity_at, pinned, source. """ + return skill_list_impl(_resolve_active_agent_id(runtime)) + + +def skill_list_impl(agent_id: str | None) -> str: + """List skills visible to ``agent_id`` (None = default lead agent).""" from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.types import SkillCategory from deerflow.skills.usage import STATE_ARCHIVED, all_entries, latest_activity_at - agent_id = _resolve_active_agent_id(runtime) allowed = _load_agent_skill_whitelist(agent_id) # 默认 lead agent 没有 per-agent 白名单 —— 改为只暴露"未归档"且"内置或属于 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/skill_manage_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/skill_manage_tool.py index 04b6bda6..ecee81a6 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/skill_manage_tool.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/skill_manage_tool.py @@ -218,14 +218,16 @@ async def _skill_manage_impl( """ # Read-only aliases — delegate to the dedicated tools so a model that # over-uses skill_manage doesn't trip on Unsupported action errors. + # Pass the active agent_id through so a custom agent's skill whitelist + # is enforced on these aliases just like on skill_list/skill_view. if action == "list": - from deerflow.tools.builtins.skill_tools import skill_list_tool + from deerflow.tools.builtins.skill_tools import _resolve_active_agent_id, skill_list_impl - return await _to_thread(skill_list_tool.invoke, {}) + return await _to_thread(skill_list_impl, _resolve_active_agent_id(runtime)) if action == "view": - from deerflow.tools.builtins.skill_tools import skill_view_tool + from deerflow.tools.builtins.skill_tools import _resolve_active_agent_id, skill_view_impl - return await _to_thread(skill_view_tool.invoke, {"name": name}) + return await _to_thread(skill_view_impl, name, _resolve_active_agent_id(runtime)) name = SkillStorage.validate_skill_name(name) lock = _get_lock(name) diff --git a/qa-client/qa_client.py b/qa-client/qa_client.py new file mode 100644 index 00000000..03a80656 --- /dev/null +++ b/qa-client/qa_client.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +"""DeerFlow 后端问答客户端(零第三方依赖,纯 Python 标准库)。 + +用法:只需修改下面的 BASE_URL(必要时改 USERNAME / LOGIN_PASSWORD), +然后调用 ask("你的问题") 即可拿到回答文本。 + + from qa_client import ask + + answer = ask("你好,介绍一下你自己") + print(answer) + +多轮对话(共用同一个 thread_id 即可保留上下文): + + tid = new_thread_id() + print(ask("我叫小明", thread_id=tid)) + print(ask("我叫什么名字?", thread_id=tid)) +""" + +from __future__ import annotations + +import json +import ssl +import urllib.error +import urllib.parse +import urllib.request +import uuid + +# ======================= 只需要修改这里 ======================= +BASE_URL = "http://47.94.209.59:2026" # 后端 Gateway 地址(改成你的服务器地址即可) +USERNAME = "api-client" # 登录用户名(免密登录,不存在会自动注册) +LOGIN_PASSWORD = "123ewq" # 仅当后端开启 require_password 口令门槛时才需要填(默认口令 123ewq) +MODEL_NAME = None # 可选:指定模型名;None = 使用后端默认模型 +TIMEOUT_SECONDS = 600 # 等待回答的超时时间(复杂问题可能跑几分钟) +# ============================================================= + +_token_cache = {"token": None} +_ssl_ctx = ssl.create_default_context() +_ssl_ctx.check_hostname = False +_ssl_ctx.verify_mode = ssl.CERT_NONE # 内网自签证书也能用 + + +def _post_json(url: str, payload: dict, token: str | None = None, timeout: int = 30) -> dict: + """POST JSON 并解析 JSON 响应。""" + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Content-Type", "application/json") + if token: + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=timeout, context=_ssl_ctx) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _login() -> str | None: + """免密用户名登录,返回 Bearer token;后端关闭鉴权时返回 None 也能正常问答。""" + if _token_cache["token"]: + return _token_cache["token"] + url = f"{BASE_URL.rstrip('/')}/api/v1/auth/login/username" + if LOGIN_PASSWORD: + url += "?" + urllib.parse.urlencode({"password": LOGIN_PASSWORD}) + try: + result = _post_json(url, {"username": USERNAME}) + _token_cache["token"] = result.get("access_token") + return _token_cache["token"] + except urllib.error.HTTPError as e: + if e.code == 401: + raise RuntimeError("登录被拒绝:后端开启了登录口令,请在 LOGIN_PASSWORD 填入正确口令(默认 123ewq)") from e + # 登录接口异常时尝试无 token 直连(后端可能处于免鉴权模式) + return None + except urllib.error.URLError: + return None + + +def _extract_answer(channel_values: dict) -> str: + """从 /api/runs/wait 返回的最终状态里取出最后一条 AI 回复文本。""" + messages = channel_values.get("messages") or [] + for msg in reversed(messages): + if not isinstance(msg, dict): + continue + if msg.get("type") not in ("ai", "AIMessageChunk", "AIMessage"): + continue + content = msg.get("content") + if isinstance(content, str) and content.strip(): + return content.strip() + if isinstance(content, list): # 结构化内容块:拼接其中的文本 + parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] + text = "".join(parts).strip() + if text: + return text + # 没有 AI 消息时,把原始返回吐出来便于排查 + return json.dumps(channel_values, ensure_ascii=False)[:2000] + + +def new_thread_id() -> str: + """生成一个新会话 id,用于多轮对话。""" + return str(uuid.uuid4()) + + +def ask(question: str, thread_id: str | None = None) -> str: + """向后端发送一个问题,阻塞直到拿到最终回答文本。 + + :param question: 要问的问题 + :param thread_id: 可选。传同一个 id 即为多轮对话(保留上下文);不传则每次新会话 + :return: AI 的回答文本 + """ + token = _login() + configurable: dict = {} + if thread_id: + configurable["thread_id"] = thread_id + if MODEL_NAME: + configurable["model_name"] = MODEL_NAME + + payload = { + "input": {"messages": [{"role": "user", "content": question}]}, + "config": {"configurable": configurable}, + } + url = f"{BASE_URL.rstrip('/')}/api/runs/wait" + try: + result = _post_json(url, payload, token=token, timeout=TIMEOUT_SECONDS) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace")[:500] + raise RuntimeError(f"请求失败 HTTP {e.code}: {body}") from e + except urllib.error.URLError as e: + raise RuntimeError(f"无法连接后端 {BASE_URL},请检查 BASE_URL 和服务是否启动: {e.reason}") from e + + return _extract_answer(result) + + +if __name__ == "__main__": + # 示例 1:单轮问答 + print(ask("你好,请简单介绍一下你自己")) + + # 示例 2:多轮对话(取消注释即可) + # tid = new_thread_id() + # print(ask("我叫小明,记住了吗?", thread_id=tid)) + # print(ask("我叫什么名字?", thread_id=tid)) From da677f87d5cb236bda7c3bfd993ba7e1809971da Mon Sep 17 00:00:00 2001 From: Administrator Date: Fri, 12 Jun 2026 12:21:25 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E5=85=AC=E5=BC=80=E9=A1=B5?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E8=89=B2=E6=94=AF=E6=8C=81=20iframe=20?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E9=85=8D=E7=BD=AE=20+=20CM=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E6=97=81=E6=8E=92=E8=A1=8C=E6=A6=9C=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 usePublicBackground:公开页(排行榜/分享/会商分享/计划任务)支持 嵌套方通过 ?bg=/?text= 查询参数或 postMessage 覆盖背景/文字色 - Header 左上角 CM助手 旁新增「排行榜」按钮,新标签打开公开排行榜页 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/core/embed/background.ts | 88 +++++++++++++++++++ .../src/pages/PublicLeaderboardPage.tsx | 5 +- .../src/pages/PublicRoundtableSharePage.tsx | 11 ++- .../src/pages/PublicScheduledTaskPage.tsx | 15 ++-- frontend-web/src/pages/PublicSharePage.tsx | 9 +- .../src/strategy-components/Header.tsx | 22 ++++- 6 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 frontend-web/src/core/embed/background.ts diff --git a/frontend-web/src/core/embed/background.ts b/frontend-web/src/core/embed/background.ts new file mode 100644 index 00000000..5b88656a --- /dev/null +++ b/frontend-web/src/core/embed/background.ts @@ -0,0 +1,88 @@ +import { useEffect, useMemo, useState } from "react"; +import { useLocation } from "react-router-dom"; + +/** + * 公开页(被第三方 iframe 嵌套)的可配置背景色。 + * + * 这些 `/public/*`、`/share/*`、`/roundtable-share/*` 页面会被别的系统用 + * ` + + + + + + +``` + +--- + +## 3. postMessage(嵌入后动态切换) + +适用于宿主站点支持用户切主题、需要 iframe 实时跟随的场景。向 iframe 的 `contentWindow` 发消息即可,**无需等待子页面回执**(监听器在应用挂载时注册)。 + +### 3.1 消息格式 + +```js +const frame = document.getElementById("deerflow-frame"); + +// 切整体明暗主题(theme 取值同 2.2) +frame.contentWindow.postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*"); + +// 改背景色 / 文字色(字段名与 URL 参数一致,bg/background、text/fg 均可) +frame.contentWindow.postMessage({ type: "deerflow:set-bg", bg: "#001529", text: "#fff" }, "*"); +// 兼容别名:type 也可写 "deerflow:set-background" +``` + +说明: + +- 两类消息相互独立,可只发其一;`set-bg` 里 `bg`、`text` 也可只传其一,未传的字段保持当前值。 +- 非法颜色值 / 不认识的 `theme` 取值会被忽略,不会清空已有设置。 +- targetOrigin 建议按需收紧(写 DeerFlow 站点的 origin 而非 `"*"`)。 + +### 3.2 宿主主题联动示例 + +```js +function syncDeerflowTheme(isDark) { + const win = document.getElementById("deerflow-frame").contentWindow; + win.postMessage({ type: "deerflow:set-theme", theme: isDark ? "dark" : "light" }, "*"); + win.postMessage( + { type: "deerflow:set-bg", bg: isDark ? "#001529" : "#ffffff" }, + "*", + ); +} + +// iframe 首次加载用 URL 参数兜底,之后切换走 postMessage +myThemeStore.subscribe((theme) => syncDeerflowTheme(theme === "dark")); +``` + +--- + +## 4. 优先级与默认行为 + +同一项配置多处传入时,按以下优先级取值(高 → 低): + +1. **postMessage** 动态设置的值 +2. hash 路由内的 URL 参数(`#/路由?theme=...`) +3. `#` 之前的 URL 参数(`?theme=...#/路由`) +4. 都没传 → DeerFlow 默认配色(浅色主题 + 页面自带 Tailwind 配色) + +其他保证: + +- 外部主题只影响**当前 iframe 的渲染**,不写 localStorage——不会污染用户直接访问 DeerFlow 主站时自己选择的主题。 +- `bg`/`text` 是内联样式,作用在页面根容器上,优先级高于主题变量;与 `theme` 叠加时以 `bg`/`text` 为准。 + +--- + +## 5. 常见问题排查 + +| 现象 | 排查 | +|------|------| +| `theme=dark` 不生效 | 确认路由属于第 1 节列出的公开页;确认参数拼在 hash 路由后(`/#/share/xx?theme=dark`),而不是写成了 `/#/share/xx#theme=dark` | +| `bg=#fff` 不生效 | URL 里 `#` 没编码会被浏览器截断,必须写 `%23fff`;或直接省略 `#` 写 `bg=fff` | +| postMessage 无反应 | 确认消息体是对象且 `type` 拼写为 `deerflow:set-theme` / `deerflow:set-bg`;确认在 iframe `load` 之后发送 | +| 颜色被忽略 | 检查是否符合 2.3 白名单格式(如 `linear-gradient(...)` 等复杂值不支持) | +| 主题切了但个别区域颜色没变 | 该区域可能使用了固定配色;把页面路由与截图反馈给 DeerFlow 维护方补充 `dark:` 样式 | diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 23999e96..7fa0e118 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -13,6 +13,7 @@ import { resolveEmbedRoundtableTheme, resolveEmbedTheme, } from "@/core/embed/params"; +import { usePublicEmbedTheme } from "@/core/embed/background"; import { readWujieProps } from "@/core/embed/wujie"; import { HashRouter, useLocation } from "react-router-dom"; @@ -31,9 +32,13 @@ import EmbedRoundtablePage from "./pages/EmbedRoundtablePage"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; +// 公开页路由前缀:这些页面会被第三方 iframe 嵌入,主题可由外部指定。 +const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"]; + function AppProviders({ children }: { children: React.ReactNode }) { const locale = useMemo(() => detectLocale(), []); const location = useLocation(); + const publicEmbedTheme = usePublicEmbedTheme(); // 嵌入路由的主题用 forcedTheme 强制:只影响当前渲染,不写 localStorage。 // 千万不要在嵌入页里调 ThemeContext.setTheme —— 它会持久化主题并打上 // 「用户已显式选择」标记。无界子应用与主站同域共享 localStorage,宿主 @@ -52,8 +57,11 @@ function AppProviders({ children }: { children: React.ReactNode }) { const raw = new URLSearchParams(location.search).get("theme")?.trim(); return raw ? resolveEmbedTheme(raw) : undefined; } + if (PUBLIC_EMBED_PREFIXES.some((p) => location.pathname.startsWith(p))) { + return publicEmbedTheme; + } return undefined; - }, [location.pathname, location.search]); + }, [location.pathname, location.search, publicEmbedTheme]); return ( {t.welcome.greeting} - {/* 用户排行榜入口:紧挨欢迎语的"CM助手",新标签打开免登录的公开排行榜页。 */} -
)}
diff --git a/frontend-web/src/components/workspace/workspace-header.tsx b/frontend-web/src/components/workspace/workspace-header.tsx index ba1767d8..c66c26db 100644 --- a/frontend-web/src/components/workspace/workspace-header.tsx +++ b/frontend-web/src/components/workspace/workspace-header.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ChevronLeft, ChevronRight, Trophy } from "lucide-react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; @@ -86,6 +86,20 @@ export function WorkspaceHeader({ className }: { className?: string }) { ) : ( titleBlock )} + {/* 用户排行榜入口:紧挨左上角的应用名,新标签打开免登录的公开排行榜页。 */} +
)} diff --git a/frontend-web/src/core/embed/background.ts b/frontend-web/src/core/embed/background.ts index 5b88656a..d4c5e9b8 100644 --- a/frontend-web/src/core/embed/background.ts +++ b/frontend-web/src/core/embed/background.ts @@ -2,19 +2,23 @@ import { useEffect, useMemo, useState } from "react"; import { useLocation } from "react-router-dom"; /** - * 公开页(被第三方 iframe 嵌套)的可配置背景色。 + * 公开页(被第三方 iframe 嵌套)的可配置外观:背景色 + 整体明暗主题。 * * 这些 `/public/*`、`/share/*`、`/roundtable-share/*` 页面会被别的系统用 - * `