From 9e14c2e2893f95f3ee640b05d2d0892323fea648 Mon Sep 17 00:00:00 2001 From: hlh Date: Mon, 8 Jun 2026 18:35:05 +0800 Subject: [PATCH 01/23] =?UTF-8?q?=E7=A6=81=E7=94=A8=E4=BA=86=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E5=BA=93=E6=8A=80=E8=83=BD=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BA=86=E7=9F=A5=E8=AF=86=E5=BA=93memory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- offline-backend-20260512/backend/config.yaml | 4 + .../backend/extensions_config.json | 2 +- .../knowledge/knowledge_base/routing.md | 124 ++++++++++++++++++ .../rules/citation_and_evidence.md | 66 ++++++++++ .../knowledge_base/rules/knowledge_usage.md | 75 +++++++++++ .../rules/missing_and_placeholder.md | 47 +++++++ .../rules/neutrality_and_perspective.md | 59 +++++++++ .../rules/realtime_news_usage.md | 72 ++++++++++ .../deerflow/agents/lead_agent/prompt.py | 35 +++-- .../harness/deerflow/agents/memory/manager.py | 25 +++- .../agents/memory/providers/builtin.py | 5 +- .../agents/memory/providers/knowledge.py | 93 +++++++++++++ .../backend/tests/test_memory_manager.py | 8 ++ 13 files changed, 588 insertions(+), 27 deletions(-) create mode 100644 offline-backend-20260512/backend/knowledge/knowledge_base/routing.md create mode 100644 offline-backend-20260512/backend/knowledge/knowledge_base/rules/citation_and_evidence.md create mode 100644 offline-backend-20260512/backend/knowledge/knowledge_base/rules/knowledge_usage.md create mode 100644 offline-backend-20260512/backend/knowledge/knowledge_base/rules/missing_and_placeholder.md create mode 100644 offline-backend-20260512/backend/knowledge/knowledge_base/rules/neutrality_and_perspective.md create mode 100644 offline-backend-20260512/backend/knowledge/knowledge_base/rules/realtime_news_usage.md create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/providers/knowledge.py diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 152ebfb2..1d5d23fe 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -187,6 +187,10 @@ tools: sandbox: use: deerflow.sandbox.local:LocalSandboxProvider allow_host_bash: true + mounts: + - host_path: C:\Users\iie\Desktop\deerflow-new\offline-backend-20260512\backend\knowledge\knowledge_base + container_path: /mnt/knowledge + read_only: true skills: path: skills diff --git a/offline-backend-20260512/backend/extensions_config.json b/offline-backend-20260512/backend/extensions_config.json index 7469d3bd..576832f5 100644 --- a/offline-backend-20260512/backend/extensions_config.json +++ b/offline-backend-20260512/backend/extensions_config.json @@ -6,7 +6,7 @@ "code-retrieval": { "enabled": true }, "news-briefing": { "enabled": true }, "workspace-search": { "enabled": true }, - "policy-knowledge": { "enabled": true }, + "policy-knowledge": { "enabled": false }, "html-page-builder": { "enabled": true }, "html-charts": { "enabled": true } } diff --git a/offline-backend-20260512/backend/knowledge/knowledge_base/routing.md b/offline-backend-20260512/backend/knowledge/knowledge_base/routing.md new file mode 100644 index 00000000..d2a82c8b --- /dev/null +++ b/offline-backend-20260512/backend/knowledge/knowledge_base/routing.md @@ -0,0 +1,124 @@ +# Knowledge Base Routing + +This file is the lightweight session-start routing memory for the policy knowledge base. + +It may be injected at conversation start. It does not replace the caller skill. The caller skill owns the final workflow and output format; this file only points to the smallest useful knowledge entry point. + +## Root + +Knowledge root: + +```text +/mnt/knowledge +``` + +Repository source path: + +```text +offline-backend-20260512/backend/knowledge/knowledge_base +``` + +## Core Principles + +Use the knowledge base as a reusable substrate: + +1. Use this file to choose the first manifest, rule, or routing file. +2. Read only the files needed for the current task. +3. Do not force the caller skill into a full policy-analysis workflow unless the caller task needs it. +4. Prefer manifests before reading many files. +5. Follow the caller skill's output structure; use knowledge files only as supporting evidence. + +## Scope + +Use this knowledge base for Taiwan elections, Cross-Strait relations, US/Japan-Taiwan relations, political rhetoric, public-opinion events, policy signals, party dynamics, influence analysis, and scenario planning. + +Do not use it for unrelated technical, lifestyle, economic, legal, medical, or private-person questions. Do not use it to produce political propaganda, campaign messaging, attacks, doxxing, private-communication analysis, or unverified allegations. + +For unclear tasks, ask a focused clarification question before reading many files. + +## Core Rules + +Read these rule files only when needed: + +```text +rules/knowledge_usage.md +rules/missing_and_placeholder.md +rules/citation_and_evidence.md +rules/realtime_news_usage.md +rules/neutrality_and_perspective.md +``` + +When to read: + +- `rules/knowledge_usage.md`: before using knowledge files for a non-trivial task, or when unsure how much to read. +- `rules/missing_and_placeholder.md`: when a file is missing, marked `[占位]`, or the knowledge base appears incomplete. +- `rules/citation_and_evidence.md`: when the answer uses knowledge-base material as evidence. +- `rules/realtime_news_usage.md`: when the task needs recent events, news facts, public-opinion developments, or source verification. +- `rules/neutrality_and_perspective.md`: when the task involves political controversy, competing claims, attribution, blame, legitimacy, or sensitive issues. + +## Directory Index + +Use `_MANIFEST.md` files as directory-level indexes before reading many files. + +```text +entities/_MANIFEST.md # people, parties, institutions, media, foreign actors +narratives/_MANIFEST.md # political narratives and discourse frames +events/_MANIFEST.md # key events, timelines, recurring patterns +primitives/_MANIFEST.md # concepts, frameworks, methods +playbooks/_MANIFEST.md # analysis methods +reference/_MANIFEST.md # reference tables and standards +data_pointers/_MANIFEST.md # external/current material notes +routing/_MANIFEST.md # legacy detailed routing files +``` + +## Route By Task + +Start with these broad entry points, then narrow through manifests: + +```text +Background or fact lookup -> reference/_MANIFEST.md, events/_MANIFEST.md, entities/_MANIFEST.md +Speech or rhetoric analysis -> playbooks/_MANIFEST.md, narratives/_MANIFEST.md, reference/_MANIFEST.md +Cross-Strait signal analysis -> playbooks/_MANIFEST.md, narratives/_MANIFEST.md, events/_MANIFEST.md +US/Taiwan/international signal -> playbooks/_MANIFEST.md, narratives/_MANIFEST.md, entities/foreign_actors/_MANIFEST.md +Election or party analysis -> playbooks/_MANIFEST.md, entities/_MANIFEST.md, narratives/domestic/_MANIFEST.md +Narrative or influence analysis -> playbooks/_MANIFEST.md, narratives/_MANIFEST.md, primitives/_MANIFEST.md +Prediction or scenarios -> playbooks/_MANIFEST.md, events/_MANIFEST.md, narratives/_MANIFEST.md +``` + +Do not use a playbook for simple background lookup unless the user asks for analysis, interpretation, comparison, prediction, or attribution. + +For multi-aspect tasks, read `playbooks/multi_playbook_orchestration.md` only after confirming that one playbook is not enough. + +## Legacy Detailed Routing + +If this lightweight routing is not specific enough, use the legacy routing files: + +```text +routing/by_question_type.md +routing/by_topic.md +routing/by_actor.md +routing/by_event.md +routing/cross_reference_rules.md +``` + +These are more detailed and may point to specific entities, narratives, events, primitives, and playbooks. Use them only when manifests are not specific enough. + +## Realtime Or Recent Facts + +The knowledge base may be stale. For recent events, news facts, public-opinion developments, policy updates, or source verification: + +1. Read `rules/realtime_news_usage.md`. +2. Use `bm25_search_news` if available. +3. Keep knowledge-base background separate from current news evidence. + +## Minimal Use Pattern + +```text +1. Identify whether the caller task needs knowledge-base support. +2. Read the relevant rule file if needed. +3. Use this routing file or a `_MANIFEST.md` to pick a small set of files. +4. Read only those files. +5. If a playbook is useful, read it; otherwise use knowledge files as supporting material. +6. Cite specific files when using knowledge-base material. +7. Return control to the caller skill's workflow and output format. +``` diff --git a/offline-backend-20260512/backend/knowledge/knowledge_base/rules/citation_and_evidence.md b/offline-backend-20260512/backend/knowledge/knowledge_base/rules/citation_and_evidence.md new file mode 100644 index 00000000..225b7733 --- /dev/null +++ b/offline-backend-20260512/backend/knowledge/knowledge_base/rules/citation_and_evidence.md @@ -0,0 +1,66 @@ +# Citation And Evidence Rules + +Use this file when knowledge-base material supports an answer, analysis, decision, or recommendation. + +## Core Rule + +When using knowledge-base content, cite or name the specific file. Do not write only "the knowledge base says". + +## Evidence Types + +Keep these categories separate: + +- **Knowledge-base background**: local Markdown files under `/mnt/knowledge`. +- **Current news evidence**: results from `bm25_search_news` or user-provided current materials. +- **User-provided material**: documents, prompts, screenshots, URLs, or files supplied in the conversation. +- **Model inference**: reasoning based on the above sources. + +## How To Refer To Knowledge Files + +For concise answers, a file-level reference is enough: + +```markdown +依据 `entities/parties/dpp_overview.md` 和 `events/key_events/event_2024_election_three_way.md`,... +``` + +For higher-stakes or contested claims, be more specific: + +```markdown +这一点来自 `playbooks/uncertainty_communication_protocol.md` 中关于信心度表达的规则。 +``` + +## Do Not Over-Cite + +Do not turn every sentence into a citation. Cite when the file materially supports: + +- A factual statement. +- A definition. +- A timeline point. +- A framework choice. +- A confidence or uncertainty judgment. +- A disputed interpretation. + +## Claims And Support + +Use this discipline: + +```text +Fact -> needs a file, news source, or user material. +Context -> can use knowledge-base background. +Inference -> must be labeled as inference. +Forecast -> must include uncertainty. +Gap -> must be explicitly marked. +``` + +## Conflict Handling + +If files conflict: + +1. State the conflict. +2. Prefer more specific, more recent, or higher-credibility material. +3. If recency matters, use `bm25_search_news`. +4. Mark residual uncertainty. + +## Realtime News Links + +For news-search results, use visible Markdown links in the final answer when available. Do not replace inline evidence with a bare source list at the end. diff --git a/offline-backend-20260512/backend/knowledge/knowledge_base/rules/knowledge_usage.md b/offline-backend-20260512/backend/knowledge/knowledge_base/rules/knowledge_usage.md new file mode 100644 index 00000000..b0edb6b6 --- /dev/null +++ b/offline-backend-20260512/backend/knowledge/knowledge_base/rules/knowledge_usage.md @@ -0,0 +1,75 @@ +# Knowledge Usage Rules + +Use this file when a caller skill needs material from the policy knowledge base. + +## Role Of The Knowledge Base + +The knowledge base is a substrate for other skills. It provides: + +- File navigation. +- Background concepts. +- Actor, event, narrative, and policy context. +- Optional domain playbooks. +- Evidence and source pointers. + +It does not automatically own the final answer structure. If the caller skill has its own workflow or output format, follow the caller skill and use knowledge-base files as supporting material. + +## Reading Discipline + +1. Start from `routing.md`, a relevant `_MANIFEST.md`, or a legacy routing file. +2. Read only files that are relevant to the current task. +3. Do not read the whole knowledge base. +4. For large directories, read `_MANIFEST.md` before opening individual files. +5. For long files, read the relevant section when possible instead of consuming the entire file. +6. Prefer existing local files and local helper APIs over ad hoc guessing. + +## When To Read A Playbook + +Read a playbook when the task needs a domain-specific analysis method, such as: + +- Signal interpretation. +- Scenario planning. +- Narrative attribution. +- Party strategy analysis. +- Escalation risk assessment. +- Election or voter-flow analysis. + +Do not force a playbook for simple background lookup or when the caller skill already provides a complete method. + +## When To Read Knowledge Files Without A Playbook + +Use knowledge files directly when the caller only needs: + +- A factual background note. +- Actor context. +- Event timeline context. +- A narrative definition. +- A concept or framework definition. +- A reference table. + +In these cases, read the relevant `_MANIFEST.md` and concrete files, then return the information in the caller skill's format. + +## File Priority + +When unsure, use this order: + +```text +routing.md + -> rules/*.md as needed + -> relevant _MANIFEST.md + -> relevant playbook if method is needed + -> concrete entities / narratives / events / primitives / reference files +``` + +## Separation From Current Events + +Knowledge files may be stale. Treat them as background and analytical context. Use `bm25_search_news` or user-provided current materials for recent facts. + +## Output Integration + +When using knowledge-base material: + +- Mention the specific file when it materially supports a claim. +- Do not say only "the knowledge base says". +- Preserve the caller skill's final format. +- Mark uncertainty if the knowledge base does not cover a key point. diff --git a/offline-backend-20260512/backend/knowledge/knowledge_base/rules/missing_and_placeholder.md b/offline-backend-20260512/backend/knowledge/knowledge_base/rules/missing_and_placeholder.md new file mode 100644 index 00000000..25548c15 --- /dev/null +++ b/offline-backend-20260512/backend/knowledge/knowledge_base/rules/missing_and_placeholder.md @@ -0,0 +1,47 @@ +# Missing And Placeholder Handling + +Use this file when a referenced knowledge file is missing, marked `[占位]`, incomplete, or not specific enough for the task. + +## Core Rule + +Do not invent missing knowledge-base content. + +If a file does not exist or is marked `[占位]`, treat that part of the knowledge base as unavailable. + +## What To Do + +1. Stop trying to repeatedly read the same missing file. +2. Check the nearest `_MANIFEST.md` or related routing file for alternatives. +3. Use adjacent existing files only when they genuinely cover the issue. +4. Clearly state what the knowledge base does not cover. +5. Mark uncertainty when the missing material affects the answer. +6. If current facts are needed, use `bm25_search_news` or user-provided materials instead of guessing. + +## Recommended Wording + +```markdown +知识库中关于【具体议题/人物/事件】的专门文件尚未建设;以下仅基于已存在的相关文件,结论置信度相应降低。 +``` + +or: + +```markdown +The knowledge base does not currently cover this point directly. I am using adjacent background files only as context, not as direct evidence. +``` + +## Do Not + +- Do not fabricate the contents of a missing file. +- Do not cite a missing or placeholder file as evidence. +- Do not hide the gap if it matters to the conclusion. +- Do not treat adjacent files as direct support unless they directly address the point. + +## Fallback Pattern + +```text +1. Identify the missing or placeholder file. +2. Read the directory _MANIFEST.md. +3. Read one or two adjacent files only if relevant. +4. Mark the coverage gap. +5. Continue with reduced confidence or ask the caller/user for more material. +``` diff --git a/offline-backend-20260512/backend/knowledge/knowledge_base/rules/neutrality_and_perspective.md b/offline-backend-20260512/backend/knowledge/knowledge_base/rules/neutrality_and_perspective.md new file mode 100644 index 00000000..7016d55a --- /dev/null +++ b/offline-backend-20260512/backend/knowledge/knowledge_base/rules/neutrality_and_perspective.md @@ -0,0 +1,59 @@ +# Neutrality And Perspective Rules + +Use this file when a task involves political controversy, competing claims, partisan conflict, attribution, blame, legitimacy, public opinion, or sensitive cross-Strait and international issues. + +## Core Rule + +Keep analysis separate from advocacy. Explain positions, incentives, evidence, and uncertainty without endorsing a political side or turning the answer into persuasion, mobilization, attack, or campaign messaging. + +## Neutral Language + +- Use descriptive terms instead of evaluative labels. +- Attribute contested claims to the actor making them. +- Quote loaded language only when it is evidence; do not adopt it as the analyst's voice. +- Avoid moralizing words such as "good", "bad", "correct", "betrayal", "patriotic", or "evil" unless describing a quoted claim. +- Do not write slogans, attack lines, campaign copy, or mobilizing calls to action. + +## Multiple Perspectives + +For contested issues, include the materially relevant perspectives needed to understand the dispute. Depending on the task, these may include: + +- Major parties or factions. +- Government institutions. +- Beijing, Taipei, Washington, Tokyo, or other foreign actors. +- Media, civil society, voters, affected communities, or policy experts. + +Do not force artificial balance when evidence quality is uneven. If one side has stronger evidence, say so and explain why. + +## Facts, Interpretations, And Inference + +Keep these layers distinct: + +```text +Fact -> what is directly supported by sources. +Position -> what an actor says or claims. +Interpretation -> how different actors frame the issue. +Inference -> the analyst's reasoning from evidence. +Forecast -> a forward-looking judgment with uncertainty. +``` + +Do not present an actor's framing as fact. Do not present inference as direct evidence. + +## Sensitive Claims + +For allegations, attribution, intent, coordination, corruption, foreign influence, or personal conduct: + +1. Require explicit evidence from knowledge files, current news, official records, or user-provided materials. +2. Identify who made the claim and what evidence supports it. +3. Mark uncertainty when evidence is incomplete, indirect, contested, or outdated. +4. Avoid naming private persons unless the task and evidence justify it. + +## Minimal Output Check + +Before answering, check: + +- Are facts, positions, interpretations, and inferences separated? +- Are contested claims attributed? +- Are materially relevant perspectives included? +- Is uncertainty marked where evidence is limited? +- Is the answer analytical rather than persuasive or mobilizing? diff --git a/offline-backend-20260512/backend/knowledge/knowledge_base/rules/realtime_news_usage.md b/offline-backend-20260512/backend/knowledge/knowledge_base/rules/realtime_news_usage.md new file mode 100644 index 00000000..e4aa0c71 --- /dev/null +++ b/offline-backend-20260512/backend/knowledge/knowledge_base/rules/realtime_news_usage.md @@ -0,0 +1,72 @@ +# Realtime News Usage + +Use this file when a task needs recent facts, current public-opinion developments, event timelines, policy updates, or source verification. + +## Core Rule + +The knowledge base is background context. It may be stale. Current facts require current material. + +If `bm25_search_news` is available, use it for: + +- Recent events. +- News facts. +- Public-opinion controversies. +- People or institution updates. +- Policy developments. +- Source checking. +- Timeline gaps. +- Conflicting claims. + +## How To Search + +Start broad only when the issue is unclear: + +```text +core subject + controversy / latest / recent progress / public opinion +``` + +Then narrow each follow-up search using concrete entities or clues from previous results: + +- Dates. +- Institutions. +- People. +- Places. +- Platform names. +- Event names. +- Official responses. +- Allegations or counter-claims. +- Specific policy names. + +Do not repeat broad synonym searches when the first result set already provides bridge terms. + +## Combining News With Knowledge Files + +Use this separation: + +```text +Knowledge files -> background, frameworks, prior patterns, actor context. +News results -> current facts, dates, latest developments, source evidence. +Inference -> your reasoning over both. +``` + +Do not treat news commentary as knowledge-base truth. Treat it as evidence that still needs source-quality assessment. + +## When To Continue Searching + +Continue searching when: + +- The timeline has gaps. +- Key actors are unclear. +- Sources conflict. +- Only one side is represented. +- The result set is old but the user asks about current status. +- A claim requires verification. + +## Output + +When using news results: + +- Link facts to visible Markdown links when URLs are available. +- Reuse the same link for repeated references to the same article. +- If no URL is available, name the title, source, and date. +- Keep news evidence separate from knowledge-base file references. diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index cea2a124..aa760141 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -637,28 +637,23 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig from deerflow.config.memory_config import get_effective_memory_config from deerflow.runtime.user_context import get_effective_user_id - user_id = get_effective_user_id() - config = get_effective_memory_config(user_id) - - if not config.enabled or not config.injection.enabled: - return "" - - manager = build_memory_manager( - user_id=user_id, - agent_id=agent_name or "default", - config=config, - ) + user_id = get_effective_user_id() + config = get_effective_memory_config(user_id) + + manager = build_memory_manager( + user_id=user_id, + agent_id=agent_name or "default", + config=config, + app_config=app_config, + ) # 只取静态块 (builtin 的 frozen snapshot + Hindsight 的模式说明)。 # Hindsight 的动态召回需要当前 query,走 MemoryMiddleware.before_model。 - memory_content = manager.build_system_prompt() - - if not memory_content.strip(): - return "" - - return f""" -{memory_content} - -""" + context_content = manager.build_system_prompt() + + if not context_content.strip(): + return "" + + return f"{context_content}\n" except Exception: logger.exception("Failed to load memory context") return "" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/manager.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/manager.py index 08c0e681..72f7e849 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/manager.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/manager.py @@ -32,6 +32,8 @@ logger = logging.getLogger(__name__) #: 内建 provider 的固定名字。 BUILTIN_PROVIDER_NAME = "builtin" +KNOWLEDGE_PROVIDER_NAME = "knowledge" +_INTERNAL_PROVIDER_NAMES = {BUILTIN_PROVIDER_NAME, KNOWLEDGE_PROVIDER_NAME} class MemoryManager: @@ -49,11 +51,11 @@ class MemoryManager: 内建 provider (``name == "builtin"``) 始终接受。外部 provider 至多 1 个, 第二个会被拒绝并 log 警告。返回是否注册成功。 """ - is_builtin = provider.name == BUILTIN_PROVIDER_NAME - if not is_builtin: + is_internal = provider.name in _INTERNAL_PROVIDER_NAMES + if not is_internal: if self._has_external: existing = next( - (p.name for p in self._providers if p.name != BUILTIN_PROVIDER_NAME), + (p.name for p in self._providers if p.name not in _INTERNAL_PROVIDER_NAMES), "unknown", ) logger.warning( @@ -220,6 +222,7 @@ def build_memory_manager( agent_id: str | None = None, thread_id: str | None = None, config: Any = None, + app_config: Any = None, ) -> MemoryManager: """按生效配置组装并初始化一个 MemoryManager。 @@ -246,7 +249,9 @@ def build_memory_manager( manager = MemoryManager() base_dir = str(get_paths().base_dir) - if getattr(config, "version", "v2") == "v1": + memory_enabled = bool(getattr(config, "enabled", True) and getattr(config.injection, "enabled", True)) + + if memory_enabled and getattr(config, "version", "v2") == "v1": # V1 模式:原版 DeerFlow memory.json + LLM 自动提取 from deerflow.agents.memory.providers.v1_builtin import V1BuiltinProvider @@ -260,7 +265,7 @@ def build_memory_manager( token_budget=v1_cfg.token_budget, ) ) - else: + elif memory_enabled: # V2 模式:USER.md / MEMORY.md + 可选 Hindsight from deerflow.agents.memory.providers.builtin import BuiltinFileProvider @@ -286,11 +291,21 @@ def build_memory_manager( except Exception as exc: # noqa: BLE001 - Hindsight 接入失败不能阻断记忆系统 logger.warning("加载 Hindsight provider 失败,退回纯 builtin:%s", exc) + try: + from deerflow.agents.memory.providers.knowledge import KnowledgeProvider + + knowledge = KnowledgeProvider(app_config=app_config) + if knowledge.is_available(): + manager.add_provider(knowledge) + except Exception as exc: # noqa: BLE001 - 知识库路由失败不能阻断对话 + logger.warning("加载 Knowledge provider 失败:%s", exc) + manager.initialize_all( user_id=user_id, agent_id=agent_id, thread_id=thread_id, base_dir=base_dir, + app_config=app_config, ) return manager diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/providers/builtin.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/providers/builtin.py index ea516f77..8189ac28 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/providers/builtin.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/providers/builtin.py @@ -227,7 +227,10 @@ class BuiltinFileProvider(MemoryProvider): 保持系统提示词稳定,利于 prefix cache。 """ blocks = [b for b in (self._snapshot.get("user"), self._snapshot.get("memory")) if b] - return "\n\n".join(blocks) + content = "\n\n".join(blocks) + if not content: + return "" + return f"\n{content}\n" def get_tool_schemas(self) -> list[dict[str, Any]]: """builtin 的 ``memory`` 工具以 LangChain BaseTool 形式独立注册, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/providers/knowledge.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/providers/knowledge.py new file mode 100644 index 00000000..fc8e78ef --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/providers/knowledge.py @@ -0,0 +1,93 @@ +"""Read-only provider for the mounted policy knowledge base. + +This provider keeps the current knowledge-base behaviour: inject the lightweight +``routing.md`` guide and let the model decide when to read concrete files. It +does not automatically retrieve snippets and never writes to the knowledge base. +""" + +from __future__ import annotations + +import logging +from functools import lru_cache +from pathlib import Path +from typing import Any + +from deerflow.agents.memory.provider import MemoryProvider + +logger = logging.getLogger(__name__) + + +@lru_cache(maxsize=16) +def _read_routing_file(host_path: str) -> str: + path = Path(host_path) + if not path.is_file(): + return "" + return path.read_text(encoding="utf-8").strip() + + +class KnowledgeProvider(MemoryProvider): + """Injects the knowledge routing guide as a read-only context provider.""" + + def __init__(self, *, app_config: Any | None = None) -> None: + self._app_config = app_config + self._routing_host_path: Path | None = None + + @property + def name(self) -> str: + return "knowledge" + + def is_available(self) -> bool: + return True + + def initialize( + self, + *, + user_id: str, + agent_id: str | None = None, + thread_id: str | None = None, + base_dir: str, + **kwargs: Any, + ) -> None: + app_config = self._app_config or kwargs.get("app_config") + if app_config is None: + from deerflow.config import get_app_config + + app_config = get_app_config() + + self._routing_host_path = None + for mount in app_config.sandbox.mounts or []: + if mount.container_path.rstrip("/") == "/mnt/knowledge": + self._routing_host_path = Path(mount.host_path) / "routing.md" + break + + def system_prompt_block(self) -> str: + if self._routing_host_path is None: + return "" + + content = _read_routing_file(str(self._routing_host_path.resolve())) + if not content: + return "" + + logger.debug("Injected knowledge routing memory from %s", self._routing_host_path) + return f""" +{content} +""" + + def prefetch(self, query: str, *, thread_id: str = "") -> str: + return "" + + def sync_turn( + self, + user_content: str, + assistant_content: str, + *, + thread_id: str = "", + turn_index: int | None = None, + ) -> None: + return None + + def get_tool_schemas(self) -> list[dict[str, Any]]: + return [] + + +__all__ = ["KnowledgeProvider"] diff --git a/offline-backend-20260512/backend/tests/test_memory_manager.py b/offline-backend-20260512/backend/tests/test_memory_manager.py index 9351696b..408ad4fe 100644 --- a/offline-backend-20260512/backend/tests/test_memory_manager.py +++ b/offline-backend-20260512/backend/tests/test_memory_manager.py @@ -84,6 +84,14 @@ class TestRegistration: assert manager.add_provider(_FakeProvider("mem0")) is False assert len(manager.providers) == 2 + def test_knowledge_does_not_consume_external_slot(self): + manager = MemoryManager() + manager.add_provider(_FakeProvider("builtin")) + manager.add_provider(_FakeProvider("knowledge")) + assert manager.add_provider(_FakeProvider("hindsight")) is True + assert manager.add_provider(_FakeProvider("mem0")) is False + assert [p.name for p in manager.providers] == ["builtin", "knowledge", "hindsight"] + def test_get_provider(self): manager = MemoryManager() builtin = _FakeProvider("builtin") From 621d4c82af768f64824505a0cd00abc25bcb71e5 Mon Sep 17 00:00:00 2001 From: ZyGuo Date: Wed, 10 Jun 2026 09:18:38 +0800 Subject: [PATCH 02/23] chore: apik --- offline-backend-20260512/backend/config.yaml | 66 ++++++++++---------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 152ebfb2..ba4e5c20 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -14,7 +14,7 @@ models: display_name: DeepSeek Chat use: langchain_openai:ChatOpenAI model: deepseek-chat - api_key: sk-e1c22b7931dc446f8b67e57878edd105 + api_key: sk-386b6ab4ce7146588bf69e4e70d2de99 base_url: https://api.deepseek.com request_timeout: 600.0 max_retries: 2 @@ -94,7 +94,7 @@ models: display_name: Qwen/Qwen3.6-27B use: langchain_openai:ChatOpenAI model: Qwen/Qwen3.6-27B - api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt + api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt base_url: https://api.siliconflow.cn/v1/ max_tokens: 4096 temperature: 1.0 @@ -202,31 +202,30 @@ auth_login: # 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。 username_login: require_password: false - password: "" + password: '' # token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url # (把该 token 直接拼成 Authorization: Bearer 头)换出用户名,再登录; # 用户名不在库里时自动注册。 token_login: enabled: false - token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo" + token_info_url: 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo' # 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer ",此项不再生效,保留仅兼容。 - service_authorization: "Bearer admin" + service_authorization: 'Bearer admin' timeout_seconds: 10 # 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致 # 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。 verify_ssl: false - ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验 + ca_cert_path: '' # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验 # 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口, # 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。 - database: backend: sqlite sqlite_dir: .deer-flow/data - pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略) - max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow - pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错 + pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略) + max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow + pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错 echo_sql: false checkpointer: @@ -238,35 +237,38 @@ checkpointer: # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 memory: enabled: true - version: v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取) - # 'v2'(USER.md/MEMORY.md + 可选 Hindsight) - provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名) - # builtin 始终启用,这里只决定 external 选哪个 - # (version=v1 时此字段忽略) + version: + v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取) + # 'v2'(USER.md/MEMORY.md + 可选 Hindsight) + provider: + hindsight # 'builtin' | 'hindsight'(外部 provider 名) + # builtin 始终启用,这里只决定 external 选哪个 + # (version=v1 时此字段忽略) # V1 专属配置 —— version=v1 时生效 v1: - max_facts: 100 # 最多保留的事实条数,超出时驱逐最低置信度的事实 + max_facts: 100 # 最多保留的事实条数,超出时驱逐最低置信度的事实 fact_confidence_threshold: 0.7 # 事实置信度阈值,低于此值不保存 - debounce_seconds: 30 # 对话结束后延迟多少秒才触发 LLM 提取 - model_name: "" # 用于事实提取的 LLM 模型名;空则继承全局默认模型 - token_budget: 2000 # 注入系统提示词的 token 预算 + debounce_seconds: 30 # 对话结束后延迟多少秒才触发 LLM 提取 + model_name: '' # 用于事实提取的 LLM 模型名;空则继承全局默认模型 + token_budget: 2000 # 注入系统提示词的 token 预算 builtin: enabled: true memory_char_limit: 2200 user_char_limit: 1375 deduplicate_on_load: true - hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务 - mode: local_external # 'cloud' | 'local_embedded' | 'local_external' + hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务 + mode: local_external # 'cloud' | 'local_embedded' | 'local_external' api_url: http://47.88.25.99:18888 - api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量 - # 注意:写成 $VAR 但环境变量未设置会导致配置加载失败 - bank_id_template: "deerflow-user-{user_id}" - work_bank_id_template: "deerflow-work-{user_id}-{agent_id}" + api_key: + '' # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量 + # 注意:写成 $VAR 但环境变量未设置会导致配置加载失败 + bank_id_template: 'deerflow-user-{user_id}' + work_bank_id_template: 'deerflow-work-{user_id}-{agent_id}' memory_mode: context # 'context' | 'tools' | 'hybrid' - bank_mission: "你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。" - bank_retain_mission: "请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。" + bank_mission: '你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。' + bank_retain_mission: '请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。' recall_budget: mid # 'low' | 'mid' | 'high' recall_max_tokens: 4096 recall_max_input_chars: 800 @@ -274,8 +276,8 @@ memory: auto_retain: true retain_async: true retain_every_n_turns: 1 - retain_user_prefix: "User" - retain_assistant_prefix: "Assistant" + retain_user_prefix: 'User' + retain_assistant_prefix: 'Assistant' # background_extraction:预留功能,代码尚未实现 —— 仅设计文档存在。 # 待对应版本支持后,取消下面整段注释即可启用(漏记兜底)。 @@ -293,7 +295,7 @@ memory: max_tokens: 2000 include_builtin: true include_hindsight: true - context_tag: "memory-context" # 包裹 Hindsight recall 的标签名 + context_tag: 'memory-context' # 包裹 Hindsight recall 的标签名 security: scan_content: true # 注入/外渗模式扫描 @@ -311,5 +313,5 @@ ai_writing: log_system: enabled: true - url: "https://your-log-system.example.com" - label: "日志系统" + url: 'https://your-log-system.example.com' + label: '日志系统' From 7fb38702473ea1a5d78069ebeaf8370a3edb0b2b Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 11 Jun 2026 08:34:09 +0800 Subject: [PATCH 03/23] =?UTF-8?q?@=20feat(summarization):=20=E9=9D=9E?= =?UTF-8?q?=E7=A0=B4=E5=9D=8F=E6=80=A7=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E2=80=94=E2=80=94=E4=BF=9D=E7=95=99=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E4=BB=85=E5=9C=A8=E8=AF=B7=E6=B1=82=E6=97=B6?= =?UTF-8?q?=E7=9E=AC=E6=80=81=E5=8E=8B=E7=BC=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重写 DeerFlowSummarizationMiddleware:将 stock before_model 的 RemoveMessage(REMOVE_ALL_MESSAGES) 改为 no-op,改在 wrap_model_call 中 瞬态压缩为 [summary, *recent],完整原始对话仍保留在 state 中,前端继续 渲染用户真实问答。summary 消息标记 hide_from_ui 且永不持久化,按 thread 缓存以避免每次模型调用都触发 LLM。前端 isHiddenFromUIMessage 额外隐藏旧 版本持久化的 name="summary" 消息。 Co-Authored-By: Claude Opus 4.8 (1M context) @ --- frontend-web/src/core/messages/utils.ts | 12 +- offline-backend-20260512/backend/CLAUDE.md | 4 +- .../backend/docs/summarization.md | 33 ++- .../middlewares/summarization_middleware.py | 238 ++++++++++++++---- 4 files changed, 228 insertions(+), 59 deletions(-) diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index e030a213..2b6ea765 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -434,7 +434,17 @@ export function findToolCallResult( } export function isHiddenFromUIMessage(message: Message) { - return message.additional_kwargs?.hide_from_ui === true; + if (message.additional_kwargs?.hide_from_ui === true) { + return true; + } + // Conversation-summary messages are context for the model only — never shown + // to the user. New summaries are injected transiently (never persisted), but + // threads compacted by older builds still carry a persisted `name: "summary"` + // HumanMessage, so hide those too. + if (message.name === "summary") { + return true; + } + return false; } /** diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index e8944de1..cab36879 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -163,7 +163,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har 6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider. 7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues 8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting -9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled) +9. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` — the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached per-thread (bounded, sticky boundary) to avoid an LLM call on every model invocation. Tests: `tests/test_summarization_middleware.py` 10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode) 11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional) 12. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model @@ -175,7 +175,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har 18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last) 8a. **ToolMetricsMiddleware** - Appended immediately after `ToolErrorHandlingMiddleware` in `_build_runtime_middlewares`, so it sits *deepest* in the `wrap_tool_call` chain. Records one `tool_call_metrics` row per tool/skill/MCP invocation — timing duration and bucketing failures into a coarse `error_category` (rate_limited / ip_blocked / auth_error / timeout / network_error / not_found / empty_result / tool_error). It catches both raised exceptions (before #8 converts them) and *self-handled* errors returned as error `ToolMessage`s (search tools that swallow HTTP 429/403). It also captures the targeted Agent Skill into `tool_call_metrics.skill_name`: the lead agent invokes a skill by `read_file`-ing its `/mnt/skills///SKILL.md` path (per the `` prompt block — there is no dedicated "use skill" tool), so `skill_name` is recovered by scanning every tool call's string arguments for the `skills/{public,custom}/{name}/` path shape (`_SKILL_PATH_RE`), plus the explicit `name` argument of `skill_view`/`skill_manage`/`skill_archive`. `skill_view`-specific success detection handles its swallowed failures (`Skill 'x' not found.` returned as a plain string rather than raised). Regression coverage: `tests/test_tool_metrics_middleware.py`. Writes are best-effort fire-and-forget. **Per-run skill merging**: `SqlToolMetricsStore.record` upserts skill rows by `(run_id, skill_name)` instead of inserting one row per call — a skill touched by several tool calls / retries inside one run collapses to a single row. Any success in the run wins (a skill that failed then finally succeeded is recorded as one `success`); a skill that only ever failed yields one `error` row whose `error_message` accumulates every attempt's message; durations are summed. Non-skill tool calls, and skill calls with no `run_id`, keep one row each. The read→merge→write is guarded by a per-event-loop `asyncio.Lock`. Surfaced by the admin `GET /api/admin/tool-metrics` router (which has a `kind=skill` filter and a per-skill `/summary` breakdown). 9. **PromptPrefixMiddleware** - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's `additional_kwargs['prompt_prefix']` by the Gateway (`app.gateway.services`) and prepended to content here at call time, so the persisted/displayed message stays prefix-free -10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled) +10. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` — the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached per-thread (bounded, sticky boundary) to avoid an LLM call on every model invocation. Tests: `tests/test_summarization_middleware.py` 11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode) 12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional) 13. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model diff --git a/offline-backend-20260512/backend/docs/summarization.md b/offline-backend-20260512/backend/docs/summarization.md index c33a6895..a3037a0f 100644 --- a/offline-backend-20260512/backend/docs/summarization.md +++ b/offline-backend-20260512/backend/docs/summarization.md @@ -4,13 +4,26 @@ DeerFlow includes automatic conversation summarization to handle long conversati ## Overview -The summarization feature uses LangChain's `SummarizationMiddleware` to monitor conversation history and trigger summarization based on configurable thresholds. When activated, it: +The summarization feature uses DeerFlow's `DeerFlowSummarizationMiddleware` (a subclass of LangChain's `SummarizationMiddleware`) to monitor conversation history and trigger summarization based on configurable thresholds. When activated, it: 1. Monitors message token counts in real-time 2. Triggers summarization when thresholds are met 3. Keeps recent messages intact while summarizing older exchanges 4. Maintains AI/Tool message pairs together for context continuity -5. Injects the summary back into the conversation +5. Substitutes the summary for the older messages **in the model request only** + +### Non-destructive (display-preserving) compression + +> **Important:** DeerFlow compresses **transiently**, not destructively. + +LangChain's stock `SummarizationMiddleware` runs in `before_model` and persists a `RemoveMessage(REMOVE_ALL_MESSAGES)` to the checkpoint — which permanently deletes the original Q&A from both the model context *and* the conversation the frontend renders. DeerFlow overrides this: + +- `before_model` / `abefore_model` are **no-ops** (the destructive path is disabled). +- Compression runs in `wrap_model_call` / `awrap_model_call`: for a single model call, the message list is replaced with `[summary, *recent]` via `request.override(messages=...)`. **Nothing is written back to state.** +- The full original messages stay in the checkpoint, so the frontend keeps showing the user's real conversation. The summary message is named `summary` and flagged `hide_from_ui`, and because it only ever lives in the transient request it never reaches the frontend at all. +- The generated summary is cached **per-thread** so we don't pay an extra LLM call on every model invocation. The cached summary is reused (no LLM call) until enough *new* messages accumulate to push the compressed context back over the trigger threshold (a sticky boundary mirroring the stock middleware's post-compaction behavior). The cache is bounded (oldest threads evicted first). + +> **Legacy threads:** conversations compacted by an older (destructive) build already lost their original messages and carry a persisted `summary` message. The frontend hides any `name: "summary"` message, so the stray summary bubble no longer shows — but those already-discarded messages cannot be recovered. ## Configuration @@ -169,10 +182,7 @@ The default LangChain prompt instructs the model to: - Messages to summarize (older messages beyond the `keep` threshold) - Messages to preserve (recent messages within the `keep` threshold) 4. **Summary Generation**: The model generates a concise summary of the older messages -5. **Context Replacement**: The message history is updated: - - All old messages are removed - - A single summary message is added - - Recent messages are preserved +5. **Context Replacement (transient)**: The message list **handed to the model for this call** is rebuilt as `[summary, *recent]`. The persisted state is left untouched — old messages are **not** removed, so the frontend still renders the full original conversation. 6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together 7. **Skill Rescue**: Before the summary is generated, the most recently loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`) are lifted out of the summarization set and prepended to the preserved tail. Selection walks newest-first under three budgets: `preserve_recent_skill_count`, `preserve_recent_skill_tokens`, and `preserve_recent_skill_tokens_per_skill`. The triggering AIMessage and all of its paired ToolMessages move together so tool_call ↔ tool_result pairing stays intact. @@ -298,8 +308,9 @@ The middleware intelligently preserves message context: ### Code Structure - **Configuration**: `packages/harness/deerflow/config/summarization_config.py` -- **Integration**: `packages/harness/deerflow/agents/lead_agent/agent.py` -- **Middleware**: Uses `langchain.agents.middleware.SummarizationMiddleware` +- **Integration**: `packages/harness/deerflow/agents/lead_agent/agent.py` (`_create_summarization_middleware`) +- **Middleware**: `packages/harness/deerflow/agents/middlewares/summarization_middleware.py` — `DeerFlowSummarizationMiddleware`, subclassing `langchain.agents.middleware.SummarizationMiddleware` but overriding compaction to be transient (`wrap_model_call`) instead of destructive (`before_model`) +- **Tests**: `tests/test_summarization_middleware.py` ### Middleware Order @@ -313,9 +324,9 @@ Summarization runs after ThreadData and Sandbox initialization but before Title ### State Management -- Summarization is stateless - configuration is loaded once at startup -- Summaries are added as regular messages in the conversation history -- The checkpointer persists the summarized history automatically +- Configuration is loaded once at startup +- Summarization is **non-destructive**: the summary replaces older messages only in the transient model request (`wrap_model_call`), never in persisted state. The checkpointer therefore keeps the full original conversation +- The per-thread summary cache lives in the middleware instance (in-memory, bounded). It is a cost optimization only — a cold/stale cache simply re-summarizes, which is always correct ## Example Configurations diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py index f2443b3f..276f7adf 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @@ -1,21 +1,41 @@ -"""Summarization middleware extensions for DeerFlow.""" +"""Summarization middleware extensions for DeerFlow. + +Unlike LangChain's stock ``SummarizationMiddleware`` (which runs in +``before_model`` and **persists** a destructive ``RemoveMessage(REMOVE_ALL_MESSAGES)`` +to the checkpoint — wiping the original Q&A from both the model context *and* +the conversation the frontend renders), this middleware compresses the history +**transiently in ``wrap_model_call``**. The full original messages stay in state +(so the UI keeps showing the user's real conversation); only the message list +handed to the model for a single call is replaced with ``[summary, *recent]``. + +The generated summary is cached per-thread so we don't pay an extra LLM call on +every model invocation: once a thread is summarized, the cached summary is reused +until enough *new* messages accumulate to push the compressed context back over +the trigger threshold (sticky boundary, mirroring the stock middleware's +post-compaction behavior). +""" from __future__ import annotations import logging -from collections.abc import Collection +from collections import OrderedDict +from collections.abc import Awaitable, Callable, Collection from dataclasses import dataclass from typing import Any, Protocol, override, runtime_checkable from langchain.agents import AgentState from langchain.agents.middleware import SummarizationMiddleware -from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage, ToolMessage +from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage from langgraph.config import get_config -from langgraph.graph.message import REMOVE_ALL_MESSAGES from langgraph.runtime import Runtime logger = logging.getLogger(__name__) +# Upper bound on the per-thread summary cache so a long-lived process serving +# many threads can't grow it without limit. Oldest entries are evicted first. +_SUMMARY_CACHE_MAX_THREADS = 512 + @dataclass(frozen=True) class SummarizationEvent: @@ -95,6 +115,22 @@ class _SkillBundle: skill_key: str +@dataclass(frozen=True) +class _SummaryCacheEntry: + """A cached, reusable summary for one thread. + + ``summarized_ids`` are the ids of the original messages folded into + ``summary_text``. The cache is only reusable while every one of those ids is + still present in the live message list and no AIMessage/ToolMessage pair was + split when the boundary was drawn (``reusable``) — otherwise filtering by id + could orphan a tool result, so we fall back to re-summarizing. + """ + + summarized_ids: frozenset[str] + summary_text: str + reusable: bool + + class DeerFlowSummarizationMiddleware(SummarizationMiddleware): """Summarization middleware with pre-compression hook dispatch and skill rescue.""" @@ -116,69 +152,181 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): self._preserve_recent_skill_count = max(0, preserve_recent_skill_count) self._preserve_recent_skill_tokens = max(0, preserve_recent_skill_tokens) self._preserve_recent_skill_tokens_per_skill = max(0, preserve_recent_skill_tokens_per_skill) + # Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text). + # Lets us reuse a summary across model calls instead of re-running the LLM every invocation. + self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = OrderedDict() + @override def before_model(self, state: AgentState, runtime: Runtime) -> dict | None: - return self._maybe_summarize(state, runtime) + """Disable the stock middleware's destructive ``before_model`` compaction. + Compression now happens transiently in :meth:`wrap_model_call`, so the + persisted conversation (and the frontend's view of it) is never mutated. + """ + return None + + @override async def abefore_model(self, state: AgentState, runtime: Runtime) -> dict | None: - return await self._amaybe_summarize(state, runtime) + """Async counterpart of :meth:`before_model` — also a no-op.""" + return None - def _maybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None: - messages = state["messages"] - self._ensure_message_ids(messages) + # ---- wrap_model_call: transient, display-preserving compression ---------- - total_tokens = self.token_counter(messages) - if not self._should_summarize(messages, total_tokens): + @override + def wrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], ModelResponse], + ) -> ModelCallResult: + compressed = self._compressed_messages(request) + if compressed is not None: + request = request.override(messages=compressed) + return handler(request) + + @override + async def awrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], Awaitable[ModelResponse]], + ) -> ModelCallResult: + compressed = await self._acompressed_messages(request) + if compressed is not None: + request = request.override(messages=compressed) + return await handler(request) + + def _compressed_messages(self, request: ModelRequest) -> list[AnyMessage] | None: + """Build the compressed message list for this call (sync summary path). + + Returns ``None`` when no compression is needed (the request is passed + through untouched). + """ + plan = self._plan_compression(request) + if plan is None: return None - - cutoff_index = self._determine_cutoff_index(messages) - if cutoff_index <= 0: - return None - - messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index) - self._fire_hooks(messages_to_summarize, preserved_messages, runtime) + action, payload = plan + if action == "reuse": + return payload # type: ignore[return-value] + # action == "summarize" + messages_to_summarize, preserved, thread_id = payload # type: ignore[misc] + self._fire_hooks(messages_to_summarize, preserved, getattr(request, "runtime", None)) summary = self._create_summary(messages_to_summarize) - new_messages = self._build_new_messages(summary) + return self._finish_summarize(messages_to_summarize, preserved, summary, thread_id) - return { - "messages": [ - RemoveMessage(id=REMOVE_ALL_MESSAGES), - *new_messages, - *preserved_messages, - ] - } + async def _acompressed_messages(self, request: ModelRequest) -> list[AnyMessage] | None: + """Async counterpart of :meth:`_compressed_messages`.""" + plan = self._plan_compression(request) + if plan is None: + return None + action, payload = plan + if action == "reuse": + return payload # type: ignore[return-value] + messages_to_summarize, preserved, thread_id = payload # type: ignore[misc] + self._fire_hooks(messages_to_summarize, preserved, getattr(request, "runtime", None)) + summary = await self._acreate_summary(messages_to_summarize) + return self._finish_summarize(messages_to_summarize, preserved, summary, thread_id) - async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None: - messages = state["messages"] + def _plan_compression( + self, + request: ModelRequest, + ) -> tuple[str, Any] | None: + """Decide what to do for this model call, doing **no** LLM work. + + Returns one of: + + - ``None`` — pass the request through unchanged (no compression needed). + - ``("reuse", compressed_messages)`` — reuse the cached summary. + - ``("summarize", (messages_to_summarize, preserved, thread_id))`` — the + caller must run the (a)sync summarizer, then call + :meth:`_finish_summarize`. + """ + messages = list(getattr(request, "messages", None) or []) + if not messages: + return None self._ensure_message_ids(messages) + thread_id = self._resolve_request_thread_id(request) - total_tokens = self.token_counter(messages) - if not self._should_summarize(messages, total_tokens): + cached = self._summary_cache.get(thread_id) if thread_id else None + present_ids = {m.id for m in messages} + if cached is not None and cached.reusable and cached.summarized_ids <= present_ids: + preserved = [m for m in messages if m.id not in cached.summarized_ids] + effective = [self._summary_message(cached.summary_text), *preserved] + if not self._should_summarize(effective, self.token_counter(effective)): + # Refresh LRU recency and reuse without an LLM call. + self._summary_cache.move_to_end(thread_id) + return ("reuse", effective) + # New messages have pushed the compressed context back over the + # threshold — fall through and re-summarize a larger prefix. + + if not self._should_summarize(messages, self.token_counter(messages)): return None cutoff_index = self._determine_cutoff_index(messages) if cutoff_index <= 0: return None - messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index) - self._fire_hooks(messages_to_summarize, preserved_messages, runtime) - summary = await self._acreate_summary(messages_to_summarize) - new_messages = self._build_new_messages(summary) + messages_to_summarize, preserved = self._partition_with_skill_rescue(messages, cutoff_index) + if not messages_to_summarize: + return None - return { - "messages": [ - RemoveMessage(id=REMOVE_ALL_MESSAGES), - *new_messages, - *preserved_messages, - ] - } + return ("summarize", (messages_to_summarize, preserved, thread_id)) + + def _finish_summarize( + self, + messages_to_summarize: list[AnyMessage], + preserved: list[AnyMessage], + summary: str, + thread_id: str | None, + ) -> list[AnyMessage]: + """Assemble the compressed message list and update the per-thread cache.""" + compressed = [self._summary_message(summary), *preserved] + + if thread_id: + summarized_ids = frozenset(m.id for m in messages_to_summarize if m.id is not None) + preserved_ids = {m.id for m in preserved if m.id is not None} + # A non-empty overlap means skill rescue split an AIMessage across the + # boundary; reconstructing ``preserved`` by id filtering later could + # orphan a tool result, so mark the entry non-reusable (re-summarize). + reusable = summarized_ids.isdisjoint(preserved_ids) + self._store_cache_entry( + thread_id, + _SummaryCacheEntry(summarized_ids=summarized_ids, summary_text=summary, reusable=reusable), + ) + + return compressed + + def _store_cache_entry(self, thread_id: str, entry: _SummaryCacheEntry) -> None: + """Insert/refresh a cache entry, evicting the oldest thread if over capacity.""" + self._summary_cache[thread_id] = entry + self._summary_cache.move_to_end(thread_id) + while len(self._summary_cache) > _SUMMARY_CACHE_MAX_THREADS: + self._summary_cache.popitem(last=False) + + @staticmethod + def _resolve_request_thread_id(request: ModelRequest) -> str | None: + """Best-effort thread id from the model request's runtime.""" + runtime = getattr(request, "runtime", None) + if runtime is None: + return None + return _resolve_thread_id(runtime) + + def _summary_message(self, summary: str) -> HumanMessage: + """Build the summary message handed to the model. + + It is named ``summary`` and flagged ``hide_from_ui`` defensively. Because + it only ever lives in the transient model request (never in persisted + state), the frontend never receives it — the user keeps seeing the + original conversation. + """ + return HumanMessage( + content=f"Here is a summary of the conversation to date:\n\n{summary}", + name="summary", + additional_kwargs={"hide_from_ui": True, "lc_source": "summarization"}, + ) @override def _build_new_messages(self, summary: str) -> list[HumanMessage]: - """Override the base implementation to let the human message with the special name 'summary'. - And this message will be ignored to display in the frontend, but still can be used as context for the model. - """ - return [HumanMessage(content=f"Here is a summary of the conversation to date:\n\n{summary}", name="summary")] + """Kept for backwards compatibility; delegates to :meth:`_summary_message`.""" + return [self._summary_message(summary)] def _partition_with_skill_rescue( self, From e97017b297e3a3e611273b038f55a89846f54b34 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 11 Jun 2026 08:34:39 +0800 Subject: [PATCH 04/23] =?UTF-8?q?feat(agents):=20=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E5=B8=B8=E7=94=A8=E9=97=AE=E9=A2=98=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=B1=95=E7=A4=BA=E5=B9=B6=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=8F=90=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 common_questions(常用问题)字段:后端贯通 agents_config、 config.yaml 读写与 create/update API,前端 Agent 类型补充字段并在 AgentChatPage 新增 AgentInfoSidebar 组件展示常用问题,点击即填充并提交。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/agent-info-sidebar.tsx | 299 ++++++++++++++++++ frontend-web/src/core/agents/types.ts | 4 + frontend-web/src/pages/AgentChatPage.tsx | 11 + .../backend/app/gateway/routers/agents.py | 32 +- .../harness/deerflow/config/agents_config.py | 3 + .../backend/tests/test_agents_backend.py | 34 +- 6 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 frontend-web/src/components/workspace/agent-info-sidebar.tsx diff --git a/frontend-web/src/components/workspace/agent-info-sidebar.tsx b/frontend-web/src/components/workspace/agent-info-sidebar.tsx new file mode 100644 index 00000000..27bd7640 --- /dev/null +++ b/frontend-web/src/components/workspace/agent-info-sidebar.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { + BotIcon, + CheckIcon, + InfoIcon, + MessageCircleQuestionIcon, + PanelRightCloseIcon, + PanelRightOpenIcon, + PencilIcon, + PlusIcon, + SparklesIcon, + XIcon, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; +import { Tooltip } from "@/components/workspace/tooltip"; +import { type Agent } from "@/core/agents"; +import { useUpdateAgent } from "@/core/agents/hooks"; +import { getStoredAuth } from "@/core/auth"; +import { useSkills } from "@/core/skills/hooks"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +/** + * Collapsible right-hand panel on the agent chat page that surfaces the agent's + * profile (name / description / skills) plus a list of frequently-asked + * questions ("常用问题"). Clicking a question sends it as a message. The agent's + * owner (or an admin) can edit the question list inline. + */ +export function AgentInfoSidebar({ + agent, + agentName, + onAskQuestion, + disabled, +}: { + agent: Agent | null | undefined; + agentName: string; + onAskQuestion: (text: string) => void; + disabled?: boolean; +}) { + const [collapsed, setCollapsed] = useState(true); + const [detailSkillName, setDetailSkillName] = useState(null); + const { skills } = useSkills(); + const updateAgent = useUpdateAgent(); + + const displayName = desensitize(agent?.name ?? agentName); + const description = agent?.description + ? desensitize(agent.description) + : undefined; + + // Owner / admin gate for inline editing of the common-questions list. + const storedAuth = useMemo(() => getStoredAuth(), []); + const canEdit = useMemo(() => { + if (!agent) return false; + if (storedAuth?.system_role === "admin") return true; + return agent.user_id != null && agent.user_id === storedAuth?.user_id; + }, [agent, storedAuth]); + + // Map a stored skill name to its Chinese display label (falls back to name). + const skillLabel = useMemo(() => { + const map = new Map(); + for (const skill of skills) { + map.set(skill.name, skill.name_zh?.trim() ? skill.name_zh : skill.name); + } + return (name: string) => map.get(name) ?? name; + }, [skills]); + + const questions = useMemo( + () => (agent?.common_questions ?? []).filter((q) => q.trim().length > 0), + [agent?.common_questions], + ); + + // --- Inline editing state --- + const [editing, setEditing] = useState(false); + const [drafts, setDrafts] = useState([]); + + const startEditing = () => { + setDrafts(questions.length > 0 ? [...questions] : [""]); + setEditing(true); + }; + + const cancelEditing = () => { + setEditing(false); + setDrafts([]); + }; + + const handleSave = async () => { + if (!agent) return; + const cleaned = drafts.map((q) => q.trim()).filter((q) => q.length > 0); + try { + await updateAgent.mutateAsync({ + id: agent.id, + request: { common_questions: cleaned }, + }); + toast.success("常用问题已保存"); + setEditing(false); + setDrafts([]); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } + }; + + // Leaving edit mode whenever the panel collapses keeps the state tidy. + useEffect(() => { + if (collapsed && editing) cancelEditing(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collapsed]); + + if (collapsed) { + return ( +
+ + + +
+ ); + } + + return ( + + ); +} diff --git a/frontend-web/src/core/agents/types.ts b/frontend-web/src/core/agents/types.ts index 21b4ceb7..1709fddf 100644 --- a/frontend-web/src/core/agents/types.ts +++ b/frontend-web/src/core/agents/types.ts @@ -16,6 +16,8 @@ export interface Agent { model?: string | null; tool_groups?: string[] | null; skills?: string[] | null; + /** Frequently-asked questions (常用问题) shown in the agent chat sidebar. */ + common_questions?: string[] | null; tags?: Tag[]; featured_order?: number | null; /** Whether the current user has favorited (收藏) this agent. */ @@ -46,6 +48,7 @@ export interface CreateAgentRequest { skills?: string[] | null; model?: string | null; tool_groups?: string[] | null; + common_questions?: string[] | null; } export interface UpdateAgentRequest { @@ -57,4 +60,5 @@ export interface UpdateAgentRequest { skills?: string[] | null; model?: string | null; tool_groups?: string[] | null; + common_questions?: string[] | null; } diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 3a834120..e26bc612 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; +import { AgentInfoSidebar } from "@/components/workspace/agent-info-sidebar"; import { AgentWelcome } from "@/components/workspace/agent-welcome"; import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ChatBox, useThreadChat } from "@/components/workspace/chats"; @@ -285,6 +286,16 @@ export default function AgentChatPage() { + + handleSubmit({ text, files: [] })} + /> diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 7848dc8c..2a9f8fc6 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -53,6 +53,11 @@ class AgentResponse(BaseModel): tool_groups: list[str] | None = None skills: list[str] | None = None + common_questions: list[str] | None = Field( + default=None, + description="Frequently-asked questions shown in the agent chat sidebar (常用问题).", + ) + tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent") featured_order: int | None = Field( @@ -101,6 +106,7 @@ class AgentCreateRequest(BaseModel): skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml") model: str | None = Field(default=None, description="Optional model override persisted to config.yaml") tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml") + common_questions: list[str] | None = Field(default=None, description="Frequently-asked questions (常用问题) persisted to config.yaml") class AgentUpdateRequest(BaseModel): @@ -114,6 +120,7 @@ class AgentUpdateRequest(BaseModel): skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)") model: str | None = Field(default=None, description="Updated model override (config.yaml)") tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)") + common_questions: list[str] | None = Field(default=None, description="Updated frequently-asked questions (常用问题, config.yaml)") class AgentSelectorResponse(BaseModel): @@ -217,6 +224,7 @@ def _write_agent_config( skills: list[str] | None = None, model: str | None = None, tool_groups: list[str] | None = None, + common_questions: list[str] | None = None, ) -> None: agent_dir = get_paths().agent_dir(agent_id) agent_dir.mkdir(parents=True, exist_ok=True) @@ -232,19 +240,27 @@ def _write_agent_config( config_data["model"] = model if tool_groups is not None: config_data["tool_groups"] = tool_groups + if common_questions is not None: + config_data["common_questions"] = common_questions with open(config_file, "w", encoding="utf-8") as f: yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True) def _load_config_extras(agent_id: str) -> dict[str, Any]: - """Read skills/model/tool_groups from the agent's config.yaml, tolerant of missing files.""" + """Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files.""" + empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None} try: cfg = load_agent_config(agent_id) except (FileNotFoundError, ValueError): - return {"skills": None, "model": None, "tool_groups": None} + return dict(empty) if cfg is None: - return {"skills": None, "model": None, "tool_groups": None} - return {"skills": cfg.skills, "model": cfg.model, "tool_groups": cfg.tool_groups} + return dict(empty) + return { + "skills": cfg.skills, + "model": cfg.model, + "tool_groups": cfg.tool_groups, + "common_questions": cfg.common_questions, + } def _write_agent_soul(agent_id: str, soul: str) -> None: @@ -404,7 +420,7 @@ def _record_to_response( can happen lazily when the agent is actually opened. """ soul: str | None = None - extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None} + extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None} if include_extras: _ensure_agent_dir_from_record(record) extras = _load_config_extras(record["id"]) @@ -425,6 +441,7 @@ def _record_to_response( model=extras["model"], tool_groups=extras["tool_groups"], skills=extras["skills"], + common_questions=extras.get("common_questions"), featured_order=record.get("featured_order"), ) @@ -725,6 +742,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque skills=request.skills, model=request.model, tool_groups=request.tool_groups, + common_questions=request.common_questions, ) _write_agent_soul(agent_id, request.soul) @@ -797,8 +815,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request: next_skills = request.skills if "skills" in fields_set else existing_extras["skills"] next_model = request.model if "model" in fields_set else existing_extras["model"] next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"] + next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"] - config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups")) + config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions")) if config_dirty: _write_agent_config( agent_id, @@ -807,6 +826,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request: skills=next_skills, model=next_model, tool_groups=next_tool_groups, + common_questions=next_common_questions, ) # Keep the listing cache in lock-step with config.yaml on every write. await store.replace_skills(agent_id, next_skills) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py index 3fddcd22..b8be6e01 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py @@ -69,6 +69,9 @@ class AgentConfig(BaseModel): # - [] (explicit empty list): no skills # - ["skill1", "skill2"]: load only the specified skills skills: list[str] | None = None + # Frequently-asked questions ("常用问题") shown in the agent chat sidebar. + # Display/UX metadata only — the runtime never reads these. None = none set. + common_questions: list[str] | None = None def load_agent_config(agent_id: str | None) -> AgentConfig | None: diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py index d835484a..e59e847e 100644 --- a/offline-backend-20260512/backend/tests/test_agents_backend.py +++ b/offline-backend-20260512/backend/tests/test_agents_backend.py @@ -177,12 +177,42 @@ def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> No ) extras = _load_config_extras(agent_id) - assert extras == {"skills": ["s1"], "model": None, "tool_groups": None} + assert extras == {"skills": ["s1"], "model": None, "tool_groups": None, "common_questions": None} def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None: extras = _load_config_extras("does-not-exist") - assert extras == {"skills": None, "model": None, "tool_groups": None} + assert extras == {"skills": None, "model": None, "tool_groups": None, "common_questions": None} + + +def test_write_agent_config_persists_common_questions(isolated_paths) -> None: + agent_id = "agent-cq-1" + questions = ["这个智能体能做什么?", "如何上传文件?"] + _write_agent_config(agent_id, name="助手", description="", common_questions=questions) + + config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml" + raw = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert raw["common_questions"] == questions + + extras = _load_config_extras(agent_id) + assert extras["common_questions"] == questions + + +def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None: + agent_id = "agent-cq-2" + _write_agent_config(agent_id, name="x", description="") + + config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml" + raw = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert "common_questions" not in raw + assert _load_config_extras(agent_id)["common_questions"] is None + + +def test_write_agent_config_explicit_empty_common_questions_is_persisted(isolated_paths) -> None: + agent_id = "agent-cq-3" + _write_agent_config(agent_id, name="x", description="", common_questions=[]) + + assert _load_config_extras(agent_id)["common_questions"] == [] def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None: From 01696f75e9a4a4211879c4302d72fb17fa5ee7a6 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 09:34:26 +0800 Subject: [PATCH 05/23] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E4=BA=BA=E7=89=A9=E8=92=B8=E9=A6=8F=E8=84=9A=E6=9C=AC=EF=BC=8C?= =?UTF-8?q?=E7=B2=BE=E7=AE=80=E6=8E=92=E8=A1=8C=E6=A6=9C=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batch-distill: 独立单文件脚本,按时间窗调用线上服务批量蒸馏人物档案 - 精简 admin_users / public_leaderboard 路由及前端排行榜页面 Co-Authored-By: Claude Opus 4.8 (1M context) --- batch-distill/README.md | 33 ++ batch-distill/distill_people.py | 504 ++++++++++++++++++ batch-distill/people.example.csv | 4 + frontend-web/src/core/admin/api.ts | 12 - .../src/pages/AdminUserLeaderboardPage.tsx | 100 +--- .../src/pages/PublicLeaderboardPage.tsx | 53 +- .../app/gateway/routers/admin_users.py | 257 +-------- .../app/gateway/routers/public_leaderboard.py | 7 - 8 files changed, 552 insertions(+), 418 deletions(-) create mode 100644 batch-distill/README.md create mode 100644 batch-distill/distill_people.py create mode 100644 batch-distill/people.example.csv diff --git a/batch-distill/README.md b/batch-distill/README.md new file mode 100644 index 00000000..5fa50926 --- /dev/null +++ b/batch-distill/README.md @@ -0,0 +1,33 @@ +# 批量人物蒸馏 → llmwiki + +一个**完全独立的单文件脚本 + 内置定时器**:读 Excel 名单,串行调用线上 DeerFlow 服务问答接口, +复用「女娲 / huashu-nuwa」技能逐个蒸馏人物,**由脚本抓取回复并保存**成 llmwiki 风格 markdown。 + +- **不联网**:只用已配置的知识库查询技能(`knowledge-search-v2`)检索资料。 +- **定时执行**:每天 **23:00 ~ 次日 08:00** 自动跑,到点暂停、次日继续。 +- **md 由 py 保存**:agent 只产出内容,脚本抓最终回复写成 `.md`(不让项目自己写文件)。 + +## 你只需要做两件事 + +1. **改一行地址**:打开 `distill_people.py`,把顶部 `BASE_URL = "..."` 改成你的线上服务地址。 +2. **放名单**:同目录放 `people.xlsx`(或 `people.csv`),**第一列 = 人名**,其余列=可选提示。 + +其余(用户名 `distiller`、口令 `123ewq`、技能名、时间窗)都已内置,不用填。 + +## 运行 + +```bash +python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行(推荐挂后台) +python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用) +python distill_people.py --redo # 忽略已完成,全部重做 +``` + +读 `.xlsx` 需要 `openpyxl`;用 `.csv` 则零额外依赖。 + +## 输出(都在 output/ 一个文件夹) + +- `<人名>.md` —— 每个人的 llmwiki 档案 +- `manifest.json` —— 执行清单(状态/耗时/run_id) +- `index.md` —— 可读清单表(谁完成/失败,点开看 md) + +**断点续跑**:已完成的人自动跳过;中途往名单里加人,下个执行窗会带上。 diff --git a/batch-distill/distill_people.py b/batch-distill/distill_people.py new file mode 100644 index 00000000..3e422cc5 --- /dev/null +++ b/batch-distill/distill_people.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +批量人物蒸馏 → llmwiki markdown(完全独立的单文件客户端 + 内置定时器) + +特点 +---- +- 串行(一个一个来)调用正在运行的 DeerFlow 服务问答接口 +- 复用线上「女娲 / huashu-nuwa」技能做提炼;**不联网**,只用已配置的 + 知识库查询技能(knowledge-search-v2)检索资料 +- agent 只负责产出内容,**markdown 由本脚本抓取最终回复后自己保存** +- 内置时间窗:每天 23:00 ~ 次日 08:00 才执行,到点自动暂停、次日继续 +- 断点续跑:已完成的人自动跳过;中途加名单也会被带上 + +你只需要改一处:下面的 BASE_URL(线上服务地址)。其余都不用填。 + +名单 +---- +脚本同目录放 people.xlsx 或 people.csv,**第一列 = 人名**,其余列=可选提示。 + +运行 +---- + python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行 + python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用) + python distill_people.py --redo # 忽略已完成,全部重做 +""" +from __future__ import annotations + +import argparse +import csv +import json +import re +import sys +import time +from datetime import datetime, time as dtime +from pathlib import Path + +try: + import requests +except ImportError: + sys.exit("缺少依赖 requests:pip install requests") + + +# ════════════════════════════════════════════════════════════════════════ +# 只改这一行:你的线上服务地址(不带结尾斜杠) +# ════════════════════════════════════════════════════════════════════════ +BASE_URL = "http://localhost:8001" +# ════════════════════════════════════════════════════════════════════════ + + +# ---- 以下都已内置,正常无需改动 ---- +USERNAME = "distiller" # 免密自动注册 +PASSWORD = "123ewq" # 共享口令(你们开了口令门) +NUWA_SKILL = "huashu-nuwa" # 女娲技能 +KB_SKILL = "knowledge-search-v2" # 知识库查询技能(不联网,用它检索) +DEPTH = "full" # full 完整 / fast 精简 + +HERE = Path(__file__).resolve().parent +OUTPUT_DIR = HERE / "output" +PEOPLE_FILE_DEFAULT = HERE / "people.xlsx" + +# 执行时间窗:23:00 ~ 次日 08:00(跨午夜) +WIN_START = dtime(23, 0) +WIN_END = dtime(8, 0) + +PER_PERSON_TIMEOUT = 1800 # 单人最长等待(秒) +POLL_INTERVAL = 5 # 轮询间隔(秒) +HTTP_TIMEOUT = 60 +TERMINAL_STATUS = {"success", "error", "timeout", "interrupted"} + + +# ================================ llmwiki 模板 ================================ +LLMWIKI_TEMPLATE = """\ +--- +name: <中文名/常用名> +aliases: [<别名/英文名...>] +type: person +era: <在世 | 历史人物> +domains: [<领域1>, <领域2>] +distilled_at: +sources_count: <检索到的来源条数> +primary_source_ratio: <一手来源占比, 0~1 的小数> +confidence: +--- + +# <人物名> + +> **一句话定位**:<此人看世界的独特方式,一句话> + +## 身份卡 +- **活跃年代**: +- **核心身份**: +- **自述(50字, 以其语气)**: + +## 核心观点(真信念 · 反复出现的主张) +1. ... + +## 心智模型(3–7 个;每个含:一句话 / 证据场景≥2 / 应用 / 局限 / 来源) +### 1. <模型名> +- **一句话**: +- **证据场景**: +- **应用方式**: +- **局限**: +- **来源**: + +## 决策启发式(5–10 条 · 如果 X 则 Y,带案例) +- 若 → + +## 表达 DNA +| 维度 | 特征 | +|------|------| +| 句式 | | +| 高频词 | | +| 幽默 | | +| 确定性 | | +| 引用习惯 | | + +## 价值观与反模式 +- **价值观排序**: +- **明确反对**: + +## 内在张力(≥2 对矛盾) +- ↔ + +## 经历时间线 +| 年份 | 事件 | 思想意义 | +|------|------|---------| +| | | | + +## 代表语录 +- “...” —(出处/年份,标一手/二手) + +## 智识谱系 +- **受影响于**: +- **影响了**: + +## 外部评价与争议 +- + +## 诚实边界 +- <至少 3 条具体局限,含信息截止日期> + +## 调研来源 +**一手(N)**: +**二手(N)**: +""" + + +def build_prompt(name: str, hints: str, depth: str) -> str: + depth_note = ( + "本次走【完整模式】:尽量覆盖著作/对话/表达/他者评价/决策/时间线 6 个维度。" + if depth == "full" + else "本次走【精简模式】:重点覆盖核心观点、心智模型、时间线三块即可。" + ) + hint_block = f"\n补充提示(来自名单其它列,可作为检索关键词):{hints}\n" if hints.strip() else "" + return f"""\ +你的任务:把人物【{name}】蒸馏成一份 llmwiki 风格的人物档案 markdown。 + +请复用本服务中已启用的「女娲 / {NUWA_SKILL}」技能的提炼方法论 +(心智模型三重验证、表达DNA分析、诚实边界)。 + +⚠️ 本环境**无法联网,严禁使用 WebSearch 等任何联网工具**。 +所有资料**只能通过已配置的知识库查询技能「{KB_SKILL}」检索获取**。 +请充分调用该知识库技能,围绕该人物多角度检索(生平、观点、著作、语录、决策、评价等), +基于检索到的内容做提炼。知识库里查不到的部分,如实在「诚实边界」标注,不要编造。 + +{depth_note} +{hint_block} +输出要求: +1. **不要写任何文件,不要调用 write_file / present_files**。 +2. 直接在你的回复正文里输出**完整中文 markdown 全文**,严格按下面模板结构 + (保留各级标题,逐项填实,不要保留尖括号占位符),**不要任何额外解说、前言或结语**: + +------------------ 模板开始 ------------------ +{LLMWIKI_TEMPLATE} +------------------ 模板结束 ------------------ + +现在开始,独立完成,不要向我提问。""" + + +# ================================ HTTP 客户端 ================================ +class DeerFlowClient: + def __init__(self, base_url: str): + self.base = base_url.rstrip("/") + self.s = requests.Session() + self.token: str | None = None + + def _headers(self) -> dict: + h = {"Content-Type": "application/json"} + if self.token: + h["Authorization"] = f"Bearer {self.token}" # 只用 Bearer,不带 cookie → 跳过 CSRF + return h + + def login(self) -> bool: + url = f"{self.base}/api/v1/auth/login/username" + params = {"password": PASSWORD} if PASSWORD else None + try: + r = self.s.post(url, params=params, json={"username": USERNAME}, timeout=HTTP_TIMEOUT) + except requests.RequestException as e: + print(f" [登录] 连接失败:{e}") + return False + if r.status_code == 200: + self.token = r.json().get("access_token") + self.s.cookies.clear() + print(f" [登录] 成功,用户={USERNAME}") + return True + print(f" [登录] 返回 {r.status_code}:{r.text[:200]}") + return False + + def ensure_skill_enabled(self, name: str) -> None: + try: + r = self.s.get(f"{self.base}/api/skills", headers=self._headers(), timeout=HTTP_TIMEOUT) + if r.status_code == 200: + skills = r.json().get("skills", []) + hit = next((x for x in skills if x.get("name") == name), None) + if hit is None: + print(f" [技能] 警告:服务里没找到 '{name}'") + return + if hit.get("enabled"): + print(f" [技能] '{name}' 已启用") + return + pr = self.s.put(f"{self.base}/api/skills/{name}", headers=self._headers(), + json={"enabled": True}, timeout=HTTP_TIMEOUT) + print(f" [技能] 启用 '{name}' → {pr.status_code}") + except requests.RequestException as e: + print(f" [技能] 检查 '{name}' 失败(忽略):{e}") + + def create_thread(self) -> str: + r = self.s.post(f"{self.base}/api/threads", headers=self._headers(), json={}, timeout=HTTP_TIMEOUT) + r.raise_for_status() + return r.json()["thread_id"] + + def start_run(self, thread_id: str, prompt: str) -> str: + body = { + "input": {"messages": [{"role": "user", "content": prompt}]}, + "excluded_tools": ["ask_clarification"], # 禁澄清,无人值守不卡住 + } + r = self.s.post(f"{self.base}/api/threads/{thread_id}/runs", + headers=self._headers(), json=body, timeout=HTTP_TIMEOUT) + r.raise_for_status() + return r.json()["run_id"] + + def poll_run(self, thread_id: str, run_id: str) -> str: + deadline = time.monotonic() + PER_PERSON_TIMEOUT + last = "" + while time.monotonic() < deadline: + try: + r = self.s.get(f"{self.base}/api/threads/{thread_id}/runs/{run_id}", + headers=self._headers(), timeout=HTTP_TIMEOUT) + if r.status_code == 200: + st = r.json().get("status", "") + if st != last: + print(f" ...状态:{st}") + last = st + if st in TERMINAL_STATUS: + return st + except requests.RequestException as e: + print(f" 轮询出错(重试):{e}") + time.sleep(POLL_INTERVAL) + return "timeout" + + def fetch_result_text(self, thread_id: str, run_id: str) -> str | None: + """抓取该 run 的最终 AI 回复文本(由 py 保存为 md)。""" + try: + r = self.s.get(f"{self.base}/api/threads/{thread_id}/runs/{run_id}/messages", + headers=self._headers(), params={"limit": 200}, timeout=HTTP_TIMEOUT) + if r.status_code == 200: + data = r.json().get("data", []) + for msg in reversed(data): + if msg.get("event_type") == "ai_message" or msg.get("type") == "ai": + txt = _extract_text(msg) + if txt and txt.strip(): + return txt + except requests.RequestException as e: + print(f" 取回复出错:{e}") + return None + + +def _extract_text(msg: dict) -> str: + c = msg.get("content") or msg.get("text") or "" + if isinstance(c, str): + return c + if isinstance(c, list): + parts = [] + for b in c: + if isinstance(b, dict): + parts.append(b.get("text") or b.get("content") or "") + else: + parts.append(str(b)) + return "\n".join(p for p in parts if p) + return str(c) + + +def _clean_markdown(text: str) -> str: + """去掉 agent 可能套上的 ```markdown ... ``` 围栏。""" + t = text.strip() + m = re.match(r"^```(?:markdown|md)?\s*\n(.*)\n```$", t, re.S) + if m: + t = m.group(1).strip() + return t + + +# ================================ 名单读取 ================================ +def slugify(name: str) -> str: + s = re.sub(r'[\\/:*?"<>|]+', "", name).strip() + s = re.sub(r"\s+", "_", s) + return s or "unnamed" + + +def read_people(path: Path) -> list[dict]: + if not path.exists(): + return [] + rows: list[list[str]] = [] + if path.suffix.lower() in (".xlsx", ".xlsm"): + try: + import openpyxl + except ImportError: + sys.exit("读取 .xlsx 需要 openpyxl:pip install openpyxl(或把名单存成 .csv)") + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + for row in wb.active.iter_rows(values_only=True): + rows.append(["" if c is None else str(c).strip() for c in row]) + elif path.suffix.lower() == ".csv": + with open(path, encoding="utf-8-sig", newline="") as f: + rows = [[c.strip() for c in r] for r in csv.reader(f)] + else: + sys.exit(f"不支持的名单格式:{path.suffix}(用 .xlsx 或 .csv)") + + if rows and re.search(r"(姓名|名字|人物|name)", rows[0][0] if rows[0] else "", re.I): + rows = rows[1:] + people, seen = [], set() + for r in rows: + if not r or not r[0].strip(): + continue + name = r[0].strip() + if name in seen: + continue + seen.add(name) + hints = " | ".join(x for x in r[1:] if x.strip()) + people.append({"name": name, "hints": hints}) + return people + + +# ================================ 时间窗 ================================ +def in_window(now: datetime | None = None) -> bool: + now = now or datetime.now() + t = now.time() + return t >= WIN_START or t < WIN_END # 跨午夜 + +def seconds_until_window(now: datetime | None = None) -> int: + now = now or datetime.now() + if in_window(now): + return 0 + target = now.replace(hour=WIN_START.hour, minute=WIN_START.minute, second=0, microsecond=0) + return max(1, int((target - now).total_seconds())) + +def sleep_interruptible(total: int) -> None: + end = time.monotonic() + total + while time.monotonic() < end: + time.sleep(min(30, max(1, int(end - time.monotonic())))) + + +# ================================ 蒸馏单人 ================================ +def distill_one(client: DeerFlowClient, person: dict, out_dir: Path, + manifest: dict, save_manifest, depth: str) -> None: + name, hints = person["name"], person["hints"] + slug = slugify(name) + md_path = out_dir / f"{slug}.md" + t0 = time.time() + + rec = {"name": name, "slug": slug, "status": "running", + "started_at": _now(), "file": f"{slug}.md"} + manifest[name] = rec + save_manifest() + + try: + tid = client.create_thread() + run_id = client.start_run(tid, build_prompt(name, hints, depth)) + status = client.poll_run(tid, run_id) + rec.update(thread_id=tid, run_id=run_id, run_status=status) + + text = client.fetch_result_text(tid, run_id) if status == "success" else None + if status == "success" and text: + md = _clean_markdown(text) + md_path.write_text(md, encoding="utf-8") + rec["status"] = "succeeded" + rec["chars"] = len(md) + print(f" ✅ 完成 → {md_path.name}({len(md)} 字,耗时 {int(time.time()-t0)}s)") + else: + rec["status"] = "failed" + rec["error"] = f"run={status}, text={'有' if text else '无'}" + if text: + (out_dir / f"{slug}.partial.md").write_text(text, encoding="utf-8") + print(f" ❌ 失败(run={status})") + except Exception as e: + rec["status"] = "failed" + rec["error"] = str(e) + print(f" ❌ 异常:{e}") + + rec["finished_at"] = _now() + rec["seconds"] = int(time.time() - t0) + manifest[name] = rec + save_manifest() + + +# ================================ 主流程 ================================ +def main(): + ap = argparse.ArgumentParser(description="批量人物蒸馏 → llmwiki(定时窗 23:00~08:00)") + ap.add_argument("excel", nargs="?", default=str(PEOPLE_FILE_DEFAULT), + help="名单文件(.xlsx 或 .csv),默认同目录 people.xlsx") + ap.add_argument("--now", action="store_true", help="忽略时间窗,立刻跑完整批(测试用)") + ap.add_argument("--redo", action="store_true", help="忽略已完成,全部重做") + args = ap.parse_args() + + excel = Path(args.excel) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + manifest_path = OUTPUT_DIR / "manifest.json" + + manifest: dict = {} + if manifest_path.exists(): + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception: + manifest = {} + + # 校验名单存在 + if not read_people(excel): + sys.exit(f"名单为空或不存在:{excel}\n请放 people.xlsx(第一列写人名)。") + + client = DeerFlowClient(BASE_URL) + if not client.login(): + sys.exit("登录失败。请检查 BASE_URL 是否指向正在运行的服务、口令是否正确。") + client.ensure_skill_enabled(NUWA_SKILL) + client.ensure_skill_enabled(KB_SKILL) + + def save_manifest(): + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + _write_index(OUTPUT_DIR, read_people(excel), manifest) + + def pending() -> list[dict]: + people = read_people(excel) + out = [] + for p in people: + r = manifest.get(p["name"], {}) + done = (not args.redo) and r.get("status") == "succeeded" and (OUTPUT_DIR / f"{slugify(p['name'])}.md").exists() + if not done: + out.append(p) + return out + + print(f"启动 | 服务={BASE_URL} | 输出={OUTPUT_DIR}") + print(f"模式={'立即跑完' if args.now else '定时窗 23:00~08:00'} | 名单={excel}") + + try: + while True: + todo = pending() + if not todo: + print(f"[{_now()}] 全部已完成,待命中(每小时复查名单)...") + if args.now: + break + sleep_interruptible(3600) + continue + + if not args.now and not in_window(): + wait = seconds_until_window() + nxt = datetime.now().replace(hour=WIN_START.hour, minute=0, second=0, microsecond=0) + print(f"[{_now()}] 不在执行窗口,剩 {len(todo)} 人待蒸;" + f"休眠到 {nxt.strftime('%H:%M')}(约 {wait//60} 分钟)...") + sleep_interruptible(wait) + continue + + # 在窗口内(或 --now):处理一个人,然后回到循环重新判断窗口/名单 + p = todo[0] + idx_total = len(read_people(excel)) + done_n = sum(1 for v in manifest.values() if v.get("status") == "succeeded") + print(f"[{_now()}] ({done_n}/{idx_total} 已完成) 蒸馏:{p['name']}") + distill_one(client, p, OUTPUT_DIR, manifest, save_manifest, DEPTH) + except KeyboardInterrupt: + print("\n已手动停止。重跑脚本会从未完成处继续。") + + ok = sum(1 for r in manifest.values() if r.get("status") == "succeeded") + fail = sum(1 for r in manifest.values() if r.get("status") == "failed") + print(f"\n结束:成功 {ok} / 失败 {fail}") + print(f"结果目录:{OUTPUT_DIR} | 清单:{manifest_path} | 索引:{OUTPUT_DIR/'index.md'}") + + +def _now() -> str: + return time.strftime("%Y-%m-%d %H:%M:%S") + + +def _write_index(out_dir: Path, people: list[dict], manifest: dict) -> None: + lines = ["# 蒸馏执行清单", "", f"更新时间:{_now()}", "", + "| # | 人物 | 状态 | 字数 | 耗时 | 文件 |", + "|---|------|------|------|------|------|"] + badge = {"succeeded": "✅完成", "failed": "❌失败", "running": "⏳进行中"} + for i, p in enumerate(people, 1): + r = manifest.get(p["name"], {}) + st = badge.get(r.get("status", ""), "⌛排队") + f = r.get("file", "") + link = f"[{f}]({f})" if r.get("status") == "succeeded" and f else "-" + lines.append(f"| {i} | {p['name']} | {st} | {r.get('chars','-')} | {r.get('seconds','-')} | {link} |") + (out_dir / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/batch-distill/people.example.csv b/batch-distill/people.example.csv new file mode 100644 index 00000000..bed69453 --- /dev/null +++ b/batch-distill/people.example.csv @@ -0,0 +1,4 @@ +姓名,领域(可选提示),资料链接或备注(可选) +查理·芒格,投资/多元思维, +理查德·费曼,物理/教学, +纳瓦尔·拉维坎特,创业/杠杆,https://nav.al/ diff --git a/frontend-web/src/core/admin/api.ts b/frontend-web/src/core/admin/api.ts index 62f1be45..4e06f4d7 100644 --- a/frontend-web/src/core/admin/api.ts +++ b/frontend-web/src/core/admin/api.ts @@ -93,15 +93,6 @@ export interface AdminLeaderboardDimension { last_seen_at: string; } -export interface AdminLeaderboardQuestion { - text: string; - user_id: string; - email: string; - thread_id: string; - run_id: string; - asked_at: string; -} - export interface AdminLeaderboardTrendPoint { date: string; runs: number; @@ -138,8 +129,6 @@ export interface AdminLeaderboardResponse { models: AdminLeaderboardDimension[]; agents: AdminLeaderboardDimension[]; run_statuses: AdminLeaderboardDimension[]; - recent_questions: AdminLeaderboardQuestion[]; - frequent_questions: AdminLeaderboardQuestion[]; trends: AdminLeaderboardTrendPoint[]; } @@ -154,7 +143,6 @@ export interface AdminUserAnalyticsResponse { models: AdminLeaderboardDimension[]; agents: AdminLeaderboardDimension[]; run_statuses: AdminLeaderboardDimension[]; - recent_questions: AdminLeaderboardQuestion[]; trends: AdminLeaderboardTrendPoint[]; } diff --git a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx index de98095f..6310a806 100644 --- a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx +++ b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx @@ -49,7 +49,6 @@ import { MarkdownContent } from "@/components/workspace/messages/markdown-conten import type { AdminLeaderboardDimension, AdminLeaderboardOverview, - AdminLeaderboardQuestion, AdminLeaderboardResponse, AdminLeaderboardSkill, AdminLeaderboardTrendPoint, @@ -66,7 +65,7 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency" type RangeState = { since: string; until: string; days?: number }; type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal"; const DEFAULT_REPORT_MODEL = "__default__"; -const LEADERBOARD_PAGE_SIZE = 20; +const LEADERBOARD_PAGE_SIZE = 10; const accentClasses: Record = { crimson: { @@ -132,11 +131,6 @@ function fmtTime(value?: string | null): string { return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } -function clampText(value: string, max = 120): string { - const text = value.trim(); - return text.length > max ? `${text.slice(0, max)}...` : text; -} - function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number { if (tab === "questions") return user.qa_count; if (tab === "tokens") return user.total_tokens; @@ -207,12 +201,6 @@ function markdownList(items: string[]): string { function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, range: RangeState): string { if (!data) return ""; - const recentQuestions = data.recent_questions - .slice(0, 8) - .map((item) => `${clampText(item.text, 120)}(${fmtTime(item.asked_at)})`); - const frequentQuestions = data.frequent_questions - .slice(0, 8) - .map((item) => clampText(item.text, 120)); const frequentUsage = [ ...data.skills.slice(0, 5).map((item) => `技能:${item.name},${fmtNumber(item.call_count)} 次调用`), ...data.tools.slice(0, 5).map((item) => `工具:${item.name},${fmtNumber(item.call_count)} 次调用`), @@ -223,12 +211,6 @@ function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, ra "", `统计周期:${range.since} 至 ${range.until}`, "", - "## 最近关注", - markdownList(recentQuestions), - "", - "## 高频问题", - markdownList(frequentQuestions), - "", "## 使用频率重点", markdownList(frequentUsage), ].join("\n"); @@ -905,43 +887,6 @@ function DimensionRanking({ ); } -function QuestionPanel({ - title, - items, - showUser = true, -}: { - title: string; - items: AdminLeaderboardQuestion[]; - showUser?: boolean; -}) { - return ( -
-
-
- -

{title}

-
- {items.length} -
- -
- {items.length === 0 &&
暂无问题记录
} - {items.map((item, index) => ( -
-
{clampText(item.text, 180)}
-
- {showUser && {item.email || item.user_id || "未知用户"}} - {fmtTime(item.asked_at)} - {item.thread_id && 会话 {item.thread_id.slice(0, 8)}} -
-
- ))} -
-
-
- ); -} - function Metric({ label, value }: { label: string; value: number }) { return (
@@ -1006,16 +951,12 @@ function UserDetailPanel({
- + - 提问 趋势 技能 维度 - - -
@@ -1354,36 +1295,7 @@ export default function AdminUserLeaderboardPage() {
{data && reportStatus === "idle" && !reportText && ( -
-
-
- - 最近关注 -
-
- {data.recent_questions.slice(0, 3).map((item, index) => ( -
- {fmtTime(item.asked_at)} - {clampText(item.text, 58)} -
- ))} - {data.recent_questions.length === 0 &&
暂无数据
} -
-
-
-
- - 高频问题 -
-
- {data.frequent_questions.slice(0, 3).map((item, index) => ( -
- {index + 1}. {clampText(item.text, 72)} -
- ))} - {data.frequent_questions.length === 0 &&
暂无数据
} -
-
+
@@ -1645,12 +1557,6 @@ export default function AdminUserLeaderboardPage() {
- - -
- - -
)}
diff --git a/frontend-web/src/pages/PublicLeaderboardPage.tsx b/frontend-web/src/pages/PublicLeaderboardPage.tsx index df783113..8513f106 100644 --- a/frontend-web/src/pages/PublicLeaderboardPage.tsx +++ b/frontend-web/src/pages/PublicLeaderboardPage.tsx @@ -34,12 +34,10 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Pagination } from "@/components/ui/pagination"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { AdminLeaderboardDimension, AdminLeaderboardOverview, - AdminLeaderboardQuestion, AdminLeaderboardResponse, AdminLeaderboardSkill, AdminLeaderboardTrendPoint, @@ -52,8 +50,10 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency" type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal"; const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const; -const LEADERBOARD_PAGE_SIZE = 20; -const RANGE_DAYS = 30; +const LEADERBOARD_PAGE_SIZE = 10; +// 公开页只统计近 7 天:跨日快照在每次请求时合并,天数越多合并越慢(尤其常问问题的 +// 模糊聚合是 O(n^2))。7 天足够展示近期热度,同时把请求耗时压到最低。 +const RANGE_DAYS = 7; const accentClasses: Record = { crimson: { @@ -98,11 +98,6 @@ function fmtDate(value?: string | null): string { return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("zh-CN", { hour12: false }); } -function clampText(value: string, max = 120): string { - const text = value.trim(); - return text.length > max ? `${text.slice(0, max)}...` : text; -} - function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number { if (tab === "questions") return user.qa_count; if (tab === "tokens") return user.total_tokens; @@ -367,12 +362,6 @@ export default function PublicLeaderboardPage() {
- - {/* 问题 */} -
- - -
)} @@ -688,40 +677,6 @@ function DimensionRanking({ ); } -function QuestionPanel({ - title, - items, -}: { - title: string; - items: AdminLeaderboardQuestion[]; -}) { - return ( -
-
-
- -

{title}

-
- {items.length} -
- -
- {items.length === 0 &&
暂无问题记录
} - {items.map((item, index) => ( -
-
{clampText(item.text, 180)}
-
- {item.email || item.user_id || "未知用户"} - {fmtTime(item.asked_at)} -
-
- ))} -
-
-
- ); -} - function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) { type TrendMetric = keyof Pick; const [metric, setMetric] = useState<"all" | TrendMetric>("all"); diff --git a/offline-backend-20260512/backend/app/gateway/routers/admin_users.py b/offline-backend-20260512/backend/app/gateway/routers/admin_users.py index ead24625..8d6b5711 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/admin_users.py +++ b/offline-backend-20260512/backend/app/gateway/routers/admin_users.py @@ -20,7 +20,6 @@ import os import re from collections.abc import AsyncIterator from contextvars import ContextVar -from difflib import SequenceMatcher from hashlib import sha256 from time import perf_counter from typing import Any @@ -269,15 +268,6 @@ class AdminLeaderboardDimension(BaseModel): last_seen_at: str = "" -class AdminLeaderboardQuestion(BaseModel): - text: str - user_id: str = "" - email: str = "" - thread_id: str = "" - run_id: str = "" - asked_at: str = "" - - class AdminLeaderboardTrendPoint(BaseModel): date: str runs: int = 0 @@ -314,8 +304,6 @@ class AdminLeaderboardResponse(BaseModel): models: list[AdminLeaderboardDimension] = Field(default_factory=list) agents: list[AdminLeaderboardDimension] = Field(default_factory=list) run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list) - recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list) - frequent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list) trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list) @@ -330,7 +318,6 @@ class AdminUserAnalyticsResponse(BaseModel): models: list[AdminLeaderboardDimension] = Field(default_factory=list) agents: list[AdminLeaderboardDimension] = Field(default_factory=list) run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list) - recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list) trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list) @@ -1014,129 +1001,6 @@ async def _query_model_rankings( ] -async def _query_recent_questions( - session, - since: datetime, - until: datetime, - *, - user_id: str | None, - limit: int, - exclude_user_ids: set[str] | None = None, - include_scheduled: bool = True, - include_admins: bool = True, - include_failed: bool = True, - cleanup_system_questions: bool = True, -) -> list[AdminLeaderboardQuestion]: - exclude_user_ids = exclude_user_ids or set() - stmt = ( - select(RunRow.first_human_message, RunRow.user_id, UserRow.email, RunRow.thread_id, RunRow.run_id, RunRow.created_at) - .outerjoin(UserRow, UserRow.id == RunRow.user_id) - .where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "") - .order_by(RunRow.created_at.desc()) - .limit(limit) - ) - if user_id: - stmt = stmt.where(RunRow.user_id == user_id) - stmt = _apply_run_scope( - stmt, - user_column=RunRow.user_id, - run_id_column=RunRow.run_id, - exclude_user_ids=exclude_user_ids, - include_admins=include_admins, - include_failed=include_failed, - include_scheduled=include_scheduled, - ) - rows = (await _execute_leaderboard_query(session, "recent_questions", stmt)).all() - questions: list[AdminLeaderboardQuestion] = [] - for r in rows: - text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip() - if cleanup_system_questions and not _is_displayable_question(text): - continue - questions.append( - AdminLeaderboardQuestion( - text=text[:500], - user_id=r[1] or "", - email=r[2] or "", - thread_id=r[3] or "", - run_id=r[4] or "", - asked_at=_iso(r[5]), - ) - ) - return questions - - -async def _query_frequent_questions( - session, - since: datetime, - until: datetime, - limit: int, - exclude_user_ids: set[str] | None = None, - include_scheduled: bool = True, - include_admins: bool = True, - include_failed: bool = True, - cleanup_system_questions: bool = True, -) -> list[AdminLeaderboardQuestion]: - exclude_user_ids = exclude_user_ids or set() - stmt = ( - select( - RunRow.first_human_message, - RunRow.user_id, - UserRow.email, - RunRow.created_at, - ) - .outerjoin(UserRow, UserRow.id == RunRow.user_id) - .where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "") - .order_by(RunRow.created_at.desc()) - .limit(min(max(limit * 20, 200), 1000)) - ) - stmt = _apply_run_scope( - stmt, - user_column=RunRow.user_id, - run_id_column=RunRow.run_id, - exclude_user_ids=exclude_user_ids, - include_admins=include_admins, - include_failed=include_failed, - include_scheduled=include_scheduled, - ) - rows = (await _execute_leaderboard_query(session, "frequent_questions", stmt)).all() - groups: list[dict] = [] - for r in rows: - text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip() - if cleanup_system_questions and not _is_displayable_question(text): - continue - key = _question_similarity_key(text) - if not key: - continue - target = None - for group in groups: - score = SequenceMatcher(None, key, group["key"]).ratio() - if score >= 0.72 or key in group["key"] or group["key"] in key: - target = group - break - if target is None: - groups.append({"key": key, "text": text, "count": 1, "user_id": r[1] or "", "email": r[2] or "", "asked_at": r[3]}) - else: - target["count"] += 1 - if r[3] and (target.get("asked_at") is None or r[3] > target["asked_at"]): - target.update({"text": text, "user_id": r[1] or "", "email": r[2] or "", "asked_at": r[3]}) - - groups.sort(key=lambda item: (item["count"], _iso(item.get("asked_at"))), reverse=True) - questions: list[AdminLeaderboardQuestion] = [] - for group in groups[:limit]: - text = group["text"][:460] - if group["count"] > 1: - text = f"{text}(相似问题 {group['count']} 次)" - questions.append( - AdminLeaderboardQuestion( - text=text, - user_id=group["user_id"], - email=group["email"], - asked_at=_iso(group.get("asked_at")), - ) - ) - return questions - - async def _query_trends( session, since: datetime, @@ -1306,10 +1170,6 @@ async def _build_user_leaderboard_response( "include_scheduled": include_scheduled, "include_admins": include_admins, } - question_common = { - **common, - "cleanup_system_questions": settings.cleanup_system_questions, - } overview = await _query_overview(session, since_dt, until_dt, **common) await _leaderboard_pause() users = await _query_user_leaderboard(session, since_dt, until_dt, **common) @@ -1326,10 +1186,6 @@ async def _build_user_leaderboard_response( await _leaderboard_pause() run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common) await _leaderboard_pause() - recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **question_common) - await _leaderboard_pause() - frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **question_common) - await _leaderboard_pause() trends = await _query_trends(session, since_dt, until_dt, **common) return AdminLeaderboardResponse( @@ -1347,8 +1203,6 @@ async def _build_user_leaderboard_response( models=models, agents=agents, run_statuses=run_statuses, - recent_questions=recent_questions, - frequent_questions=frequent_questions, trends=trends, ) @@ -1572,95 +1426,6 @@ def _merge_dimensions_from_daily( return sorted(merged_items, key=lambda item: (item.count, _sort_datetime(item.last_seen_at)), reverse=True)[:limit] -def _merge_recent_questions_from_daily( - responses: list[AdminLeaderboardResponse], - limit: int, -) -> list[AdminLeaderboardQuestion]: - questions: list[AdminLeaderboardQuestion] = [] - seen: set[str] = set() - for response in responses: - for question in response.recent_questions: - key = question.run_id or f"{question.user_id}:{question.asked_at}:{question.text}" - if key in seen: - continue - seen.add(key) - questions.append(question) - questions.sort(key=lambda item: _sort_datetime(item.asked_at), reverse=True) - return questions[:limit] - - -_FREQUENT_QUESTION_COUNT_RE = re.compile(r"(相似问题\s+(\d+)\s+次)$") - - -def _split_frequent_question_count(text: str) -> tuple[str, int]: - match = _FREQUENT_QUESTION_COUNT_RE.search(text) - if not match: - return text, 1 - return _FREQUENT_QUESTION_COUNT_RE.sub("", text).strip(), max(1, int(match.group(1))) - - -def _merge_frequent_questions_from_daily( - responses: list[AdminLeaderboardResponse], - limit: int, -) -> list[AdminLeaderboardQuestion]: - groups: list[dict] = [] - for response in responses: - for question in response.frequent_questions: - base_text, count = _split_frequent_question_count(question.text) - key = _question_similarity_key(base_text) - if not key: - continue - target = None - for group in groups: - score = SequenceMatcher(None, key, group["key"]).ratio() - if score >= 0.72 or key in group["key"] or group["key"] in key: - target = group - break - if target is None: - groups.append( - { - "key": key, - "text": base_text, - "count": count, - "user_id": question.user_id, - "email": question.email, - "thread_id": question.thread_id, - "run_id": question.run_id, - "asked_at": question.asked_at, - } - ) - else: - target["count"] += count - if _sort_datetime(question.asked_at) > _sort_datetime(target.get("asked_at")): - target.update( - { - "text": base_text, - "user_id": question.user_id, - "email": question.email, - "thread_id": question.thread_id, - "run_id": question.run_id, - "asked_at": question.asked_at, - } - ) - groups.sort(key=lambda item: (item["count"], _sort_datetime(item.get("asked_at"))), reverse=True) - questions: list[AdminLeaderboardQuestion] = [] - for group in groups[:limit]: - text_value = group["text"][:460] - if group["count"] > 1: - text_value = f"{text_value}(相似问题 {group['count']} 次)" - questions.append( - AdminLeaderboardQuestion( - text=text_value, - user_id=group["user_id"], - email=group["email"], - thread_id=group["thread_id"], - run_id=group["run_id"], - asked_at=group["asked_at"], - ) - ) - return questions - - def _merge_trends_from_daily(responses: list[AdminLeaderboardResponse]) -> list[AdminLeaderboardTrendPoint]: trends_by_date: dict[str, AdminLeaderboardTrendPoint] = {} for response in responses: @@ -1719,8 +1484,6 @@ def _merge_daily_leaderboard_payloads( models=_merge_dimensions_from_daily(responses, "models", limit), agents=_merge_dimensions_from_daily(responses, "agents", limit), run_statuses=_merge_dimensions_from_daily(responses, "run_statuses", limit), - recent_questions=_merge_recent_questions_from_daily(responses, limit), - frequent_questions=_merge_frequent_questions_from_daily(responses, limit), trends=_merge_trends_from_daily(responses), ) @@ -2267,8 +2030,6 @@ async def _build_leaderboard_report_prompt( models = snapshot.models agents = snapshot.agents run_statuses = snapshot.run_statuses - recent_questions = snapshot.recent_questions - frequent_questions = snapshot.frequent_questions trends = snapshot.trends top_users = snapshot.users_by_activity[:12] context = { @@ -2280,8 +2041,6 @@ async def _build_leaderboard_report_prompt( "top_models": [m.model_dump() for m in models[:12]], "agents": [a.model_dump() for a in agents[:12]], "run_statuses": [s.model_dump() for s in run_statuses[:12]], - "frequent_questions": [q.model_dump() for q in frequent_questions[:30]], - "recent_questions": [q.model_dump() for q in recent_questions[:40]], "trends": [t.model_dump() for t in trends[-21:]], } return ( @@ -2289,12 +2048,10 @@ async def _build_leaderboard_report_prompt( "报告需要有结论,不要只罗列数字;请使用 Markdown 标题、列表和表格,让前端可以直接渲染。" "重点要求:" "1) 你是专门负责分析用户行为的专家,不要自称企业 AI 应用运营分析师;" - "2) 优先突出用户最近常用问题、最近关注的实时问题、事件、主题和业务方向,必须引用 recent_questions 中的近期问题作为依据;" - "3) 单独分析 frequent_questions,归纳高频关注簇,说明这些高频问题反映出的需求强度和重复频率;" - "4) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;" - "5) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;" - "6) 给管理员 3-5 条可执行建议,建议应围绕用户关注主题、资源配置、内容供给、培训引导和高频能力优化。" - "输出结构:摘要、最近关注、高频问题与频率、重点用户行为、常用能力、趋势观察、行动建议。" + "2) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;" + "3) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;" + "4) 给管理员 3-5 条可执行建议,建议应围绕资源配置、内容供给、培训引导和高频能力优化。" + "输出结构:摘要、重点用户行为、常用能力、趋势观察、行动建议。" "\n\n统计数据如下:\n" f"{json.dumps(context, ensure_ascii=False, default=str)}" ) @@ -2452,10 +2209,6 @@ async def get_user_analytics( "include_scheduled": settings.include_scheduled, "include_admins": settings.include_admins, } - question_common = { - **common, - "cleanup_system_questions": settings.cleanup_system_questions, - } since_dt, until_dt, range_days = _resolve_range(days, since, until) async with session_factory() as session: await _set_analytics_read_timeout(session) @@ -2470,7 +2223,6 @@ async def get_user_analytics( models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit, **common) agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="agent", limit=limit, **common) run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="status", limit=limit, **common) - recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=user_id, limit=limit, **question_common) trends = await _query_trends(session, since_dt, until_dt, user_id=user_id, **common) return AdminUserAnalyticsResponse( @@ -2484,7 +2236,6 @@ async def get_user_analytics( models=models, agents=agents, run_statuses=run_statuses, - recent_questions=recent_questions, trends=trends, ) diff --git a/offline-backend-20260512/backend/app/gateway/routers/public_leaderboard.py b/offline-backend-20260512/backend/app/gateway/routers/public_leaderboard.py index b856d6e2..7d0d588c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/public_leaderboard.py +++ b/offline-backend-20260512/backend/app/gateway/routers/public_leaderboard.py @@ -62,13 +62,6 @@ def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse: user.email = _mask_email(user.email) user.user_id = hashed(user.user_id) - for bucket in (resp.recent_questions, resp.frequent_questions): - for question in bucket: - question.email = _mask_email(question.email) - question.user_id = hashed(question.user_id) - question.thread_id = "" - question.run_id = "" - return resp From 17bb3715d1cfdf36cef117f811ee351d4c4ac151 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 09:52:27 +0800 Subject: [PATCH 06/23] =?UTF-8?q?feat(agents):=20=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E9=97=AE=E7=AD=94=E9=A1=B5=E8=90=BD=E5=9C=B0=E6=80=81?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=B8=B8=E7=94=A8=E9=97=AE=E9=A2=98=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E5=B9=B6=E9=BB=98=E8=AE=A4=E5=B1=95=E5=BC=80=E7=AE=80?= =?UTF-8?q?=E4=BB=8B=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 落地页 Hero 以"Hi,我是{名称}"问候,将管理员配置的常用问题渲染为可点击的引导气泡,点击即发起对话 - 右侧智能体简介栏改为默认展开,作为主要"专家信息"卡片,仍可手动收起 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/agent-info-sidebar.tsx | 5 +- .../components/workspace/agent-welcome.tsx | 115 ++++++++++++------ frontend-web/src/pages/AgentChatPage.tsx | 11 +- 3 files changed, 90 insertions(+), 41 deletions(-) diff --git a/frontend-web/src/components/workspace/agent-info-sidebar.tsx b/frontend-web/src/components/workspace/agent-info-sidebar.tsx index 27bd7640..803add70 100644 --- a/frontend-web/src/components/workspace/agent-info-sidebar.tsx +++ b/frontend-web/src/components/workspace/agent-info-sidebar.tsx @@ -43,7 +43,10 @@ export function AgentInfoSidebar({ onAskQuestion: (text: string) => void; disabled?: boolean; }) { - const [collapsed, setCollapsed] = useState(true); + // Expanded by default: the profile card is meant to be the agent's primary + // "expert info" panel, not a hidden drawer. Users can still collapse it on + // narrow screens via the header toggle. + const [collapsed, setCollapsed] = useState(false); const [detailSkillName, setDetailSkillName] = useState(null); const { skills } = useSkills(); const updateAgent = useUpdateAgent(); diff --git a/frontend-web/src/components/workspace/agent-welcome.tsx b/frontend-web/src/components/workspace/agent-welcome.tsx index d154becc..1fb8038d 100644 --- a/frontend-web/src/components/workspace/agent-welcome.tsx +++ b/frontend-web/src/components/workspace/agent-welcome.tsx @@ -1,39 +1,76 @@ -"use client"; - -import { BotIcon } from "lucide-react"; - -import { type Agent } from "@/core/agents"; -import { desensitize } from "@/lib/desensitize"; -import { cn } from "@/lib/utils"; - -export function AgentWelcome({ - className, - agent, - agentName, -}: { - className?: string; - agent: Agent | null | undefined; - agentName: string; -}) { - const displayName = desensitize(agent?.name ?? agentName); - const description = agent?.description - ? desensitize(agent.description) - : undefined; - - return ( -
-
- -
-
{displayName}
- {description && ( -

{description}

- )} -
- ); -} +"use client"; + +import { BotIcon } from "lucide-react"; + +import { type Agent } from "@/core/agents"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +/** + * Landing (home) hero shown above the input box before the first message of an + * agent conversation. Mirrors the qclaw landing screen: centered avatar, a + * "Hi,我是{name}" greeting, the agent's one-line description as a slogan, and + * the agent's admin-configured common questions surfaced as clickable starter + * pills. Clicking a pill sends it as the opening message via `onAskQuestion`. + */ +export function AgentWelcome({ + className, + agent, + agentName, + onAskQuestion, + disabled, +}: { + className?: string; + agent: Agent | null | undefined; + agentName: string; + onAskQuestion?: (text: string) => void; + disabled?: boolean; +}) { + const displayName = desensitize(agent?.name ?? agentName); + const description = agent?.description + ? desensitize(agent.description) + : undefined; + const questions = (agent?.common_questions ?? []).filter( + (q) => q.trim().length > 0, + ); + + return ( +
+
+ +
+
+ Hi,我是{displayName} +
+ {description && ( +

+ {description} +

+ )} + {onAskQuestion && questions.length > 0 && ( +
+ {questions.slice(0, 6).map((question, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index e26bc612..1cf155c2 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -268,7 +268,16 @@ export default function AgentChatPage() { context={settings.context} extraHeader={ isLandingState && ( - + + handleSubmit({ text, files: [] }) + } + disabled={ + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" + } + /> ) } disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} From 3845d5090b1cdefbf6811c67969bc4228add61f8 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 10:24:53 +0800 Subject: [PATCH 07/23] =?UTF-8?q?feat(knowledge):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=AF=BC=E5=85=A5=E6=B2=89=E6=B7=80=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=EF=BC=8C=E7=9F=A5=E8=AF=86=E5=9B=BE=E8=B0=B1=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96=E9=9A=8F=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 /api/knowledge/ingest/file:上传 md/txt/pdf/word/ppt/excel, 转 Markdown 后走 wiki-capture LLM 蒸馏为陈述式知识笔记,失败回退逐字草稿 - service.capture_file + 文档专用蒸馏提示词,按主题拆分 1~4 页 - 知识图谱导出 HTML 支持 ?theme=light|dark,布局稳定后冻结, 按度数缩放节点,悬停/点击聚焦邻居 - 前端知识库页新增「导入文件」对话框与 ingestKnowledgeFile API - 智能体问答页与简介侧边栏 UI 优化;批量人物蒸馏脚本提取正文逻辑修正 Co-Authored-By: Claude Opus 4.8 (1M context) --- batch-distill/distill_people.py | 64 +++-- .../workspace/agent-info-sidebar.tsx | 206 ++++--------- .../components/workspace/agent-welcome.tsx | 8 +- .../workspace/agents/agent-card.tsx | 62 +++- frontend-web/src/core/knowledge/api.ts | 24 ++ frontend-web/src/core/knowledge/hooks.ts | 11 + frontend-web/src/pages/AgentChatPage.tsx | 245 ++++++++-------- frontend-web/src/pages/KnowledgeBasePage.tsx | 119 ++++++++ frontend-web/src/pages/KnowledgeGraphPage.tsx | 12 +- offline-backend-20260512/backend/CLAUDE.md | 4 +- .../backend/app/gateway/routers/knowledge.py | 85 +++++- .../harness/deerflow/knowledge/exporter.py | 270 +++++++++++++++--- .../deerflow/knowledge/llm_extractor.py | 159 ++++++++++- .../harness/deerflow/knowledge/service.py | 123 ++++++++ 14 files changed, 1040 insertions(+), 352 deletions(-) diff --git a/batch-distill/distill_people.py b/batch-distill/distill_people.py index 3e422cc5..d5c4d171 100644 --- a/batch-distill/distill_people.py +++ b/batch-distill/distill_people.py @@ -44,7 +44,7 @@ except ImportError: # ════════════════════════════════════════════════════════════════════════ # 只改这一行:你的线上服务地址(不带结尾斜杠) # ════════════════════════════════════════════════════════════════════════ -BASE_URL = "http://localhost:8001" +BASE_URL = "http://47.94.209.59:2026/" # ════════════════════════════════════════════════════════════════════════ @@ -57,7 +57,7 @@ DEPTH = "full" # full 完整 / fast 精简 HERE = Path(__file__).resolve().parent OUTPUT_DIR = HERE / "output" -PEOPLE_FILE_DEFAULT = HERE / "people.xlsx" +PEOPLE_FILE_DEFAULT = HERE / "people.example.csv" # 执行时间窗:23:00 ~ 次日 08:00(跨午夜) WIN_START = dtime(23, 0) @@ -260,35 +260,63 @@ class DeerFlowClient: return "timeout" def fetch_result_text(self, thread_id: str, run_id: str) -> str | None: - """抓取该 run 的最终 AI 回复文本(由 py 保存为 md)。""" + """抓取该 thread 的最终 AI 回复文本(由 py 保存为 md)。 + + 注意:本部署的 `/runs/{rid}/messages`、`/messages`、`/events` 端点都返回空 + (事件库按登录用户过滤,后台 worker 写入时未带 user_id → 读不到)。 + 可靠来源是 LangGraph 检查点状态 `/api/threads/{tid}/state` → values.messages, + 其中每条消息形如 {"type": "human"|"ai"|"tool", "content": , ...}。 + 取最后一条有正文的 ai 消息即可。 + """ try: - r = self.s.get(f"{self.base}/api/threads/{thread_id}/runs/{run_id}/messages", - headers=self._headers(), params={"limit": 200}, timeout=HTTP_TIMEOUT) - if r.status_code == 200: - data = r.json().get("data", []) - for msg in reversed(data): - if msg.get("event_type") == "ai_message" or msg.get("type") == "ai": - txt = _extract_text(msg) - if txt and txt.strip(): - return txt + r = self.s.get(f"{self.base}/api/threads/{thread_id}/state", + headers=self._headers(), timeout=HTTP_TIMEOUT) + if r.status_code != 200: + print(f" 取回复返回 {r.status_code}:{r.text[:200]}") + return None + messages = (r.json().get("values") or {}).get("messages") or [] + # 从后往前找最后一条「有正文」的 AI 回复(跳过只含工具调用的中间消息) + for msg in reversed(messages): + if not isinstance(msg, dict) or msg.get("type") != "ai": + continue + txt = _extract_text(msg) + if txt and txt.strip(): + return txt except requests.RequestException as e: print(f" 取回复出错:{e}") return None -def _extract_text(msg: dict) -> str: - c = msg.get("content") or msg.get("text") or "" +def _extract_text(payload) -> str: + """从一条消息的 content 里提取纯文本。 + + payload 可能是: + - LangChain 消息的 model_dump 字典(正文在 payload["content"]) + - 直接的字符串 + - 内容块列表([{"type":"text","text":"..."}, ...]) + """ + if payload is None: + return "" + # 若是整条消息的 model_dump,正文在其 content 字段 + if isinstance(payload, dict): + c = payload.get("content", payload.get("text", "")) + else: + c = payload if isinstance(c, str): return c if isinstance(c, list): parts = [] for b in c: if isinstance(b, dict): - parts.append(b.get("text") or b.get("content") or "") - else: - parts.append(str(b)) + # 只取文本块,跳过 tool_use / thinking 等非文本块 + if b.get("type") in (None, "text", "text_block"): + parts.append(b.get("text") or b.get("content") or "") + elif b.get("text"): + parts.append(b["text"]) + elif isinstance(b, str): + parts.append(b) return "\n".join(p for p in parts if p) - return str(c) + return str(c) if c else "" def _clean_markdown(text: str) -> str: diff --git a/frontend-web/src/components/workspace/agent-info-sidebar.tsx b/frontend-web/src/components/workspace/agent-info-sidebar.tsx index 803add70..b9f03351 100644 --- a/frontend-web/src/components/workspace/agent-info-sidebar.tsx +++ b/frontend-web/src/components/workspace/agent-info-sidebar.tsx @@ -2,35 +2,31 @@ import { BotIcon, - CheckIcon, InfoIcon, MessageCircleQuestionIcon, PanelRightCloseIcon, PanelRightOpenIcon, - PencilIcon, - PlusIcon, SparklesIcon, - XIcon, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; +import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { Tooltip } from "@/components/workspace/tooltip"; import { type Agent } from "@/core/agents"; -import { useUpdateAgent } from "@/core/agents/hooks"; -import { getStoredAuth } from "@/core/auth"; import { useSkills } from "@/core/skills/hooks"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; /** - * Collapsible right-hand panel on the agent chat page that surfaces the agent's - * profile (name / description / skills) plus a list of frequently-asked - * questions ("常用问题"). Clicking a question sends it as a message. The agent's - * owner (or an admin) can edit the question list inline. + * Collapsible right-hand "智能体简介" panel on the agent chat page. It is a + * read-only window onto the agent's profile — avatar, name, slogan/description, + * the admin-authored SOUL.md as a long-form blurb, the skill whitelist, and the + * admin-configured common questions surfaced as quick-ask entries. Clicking a + * question sends it as a message. + * + * Editing the common questions lives in the "编辑智能体" dialog (agent-card), NOT + * here — this panel never offers an edit affordance or an admin hint. */ export function AgentInfoSidebar({ agent, @@ -49,20 +45,12 @@ export function AgentInfoSidebar({ const [collapsed, setCollapsed] = useState(false); const [detailSkillName, setDetailSkillName] = useState(null); const { skills } = useSkills(); - const updateAgent = useUpdateAgent(); const displayName = desensitize(agent?.name ?? agentName); const description = agent?.description ? desensitize(agent.description) : undefined; - - // Owner / admin gate for inline editing of the common-questions list. - const storedAuth = useMemo(() => getStoredAuth(), []); - const canEdit = useMemo(() => { - if (!agent) return false; - if (storedAuth?.system_role === "admin") return true; - return agent.user_id != null && agent.user_id === storedAuth?.user_id; - }, [agent, storedAuth]); + const soul = agent?.soul?.trim() ? desensitize(agent.soul) : undefined; // Map a stored skill name to its Chinese display label (falls back to name). const skillLabel = useMemo(() => { @@ -78,45 +66,9 @@ export function AgentInfoSidebar({ [agent?.common_questions], ); - // --- Inline editing state --- - const [editing, setEditing] = useState(false); - const [drafts, setDrafts] = useState([]); - - const startEditing = () => { - setDrafts(questions.length > 0 ? [...questions] : [""]); - setEditing(true); - }; - - const cancelEditing = () => { - setEditing(false); - setDrafts([]); - }; - - const handleSave = async () => { - if (!agent) return; - const cleaned = drafts.map((q) => q.trim()).filter((q) => q.length > 0); - try { - await updateAgent.mutateAsync({ - id: agent.id, - request: { common_questions: cleaned }, - }); - toast.success("常用问题已保存"); - setEditing(false); - setDrafts([]); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } - }; - - // Leaving edit mode whenever the panel collapses keeps the state tidy. - useEffect(() => { - if (collapsed && editing) cancelEditing(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [collapsed]); - if (collapsed) { return ( -
+
-
+
{/* Identity */} -
-
- +
+
+
-
+
{displayName}
{description && ( -

+

{description}

)}
+ {/* Soul — the agent's long-form personality blurb. */} + {soul && ( +
+

Soul

+

+ {soul} +

+
+ )} + {/* Skills */} {agent?.skills && agent.skills.length > 0 && ( -
-
+
+

技能 -

-
+ +
{agent.skills.map((name) => ( ))}
-
+ )} - {/* Common questions */} -
-
-
+ {/* Common questions — read-only quick-ask entries. */} + {questions.length > 0 && ( +
+

- 常用问题 -

- {canEdit && !editing && ( - - - - )} -
- - {editing ? ( -
- {drafts.map((value, index) => ( -
- { - const next = [...drafts]; - next[index] = e.currentTarget.value; - setDrafts(next); - }} - className="h-8 text-xs" - /> - -
- ))} - -
- - -
-
- ) : questions.length > 0 ? ( -
+ 推荐问题 + +
{questions.map((question, index) => ( ))}
- ) : ( -

- {canEdit ? "还没有常用问题,点击上方编辑添加。" : "暂无常用问题。"} -

- )} -
+ + )}
-
- +
+
-
+
Hi,我是{displayName}
{description && ( -

+

{description}

)} diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index f47b010d..427710cb 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -8,6 +8,7 @@ import { LockIcon, MessageSquareIcon, PinIcon, + PlusIcon, SearchIcon, ShieldIcon, SparklesIcon, @@ -16,6 +17,7 @@ import { TagsIcon, Trash2Icon, UserIcon, + XIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; import { @@ -207,6 +209,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar const [editName, setEditName] = useState(agent.name); const [editDescription, setEditDescription] = useState(agent.description ?? ""); const [editSoul, setEditSoul] = useState(agent.soul ?? ""); + // Common questions (常用问题) are authored here in the edit dialog and read + // back read-only on the agent chat page. Keep one trailing blank row so there + // is always an empty input to type the next question into. + const [editCommonQuestions, setEditCommonQuestions] = useState( + agent.common_questions ?? [], + ); const [editPublished, setEditPublished] = useState(agent.published); const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private); const [selectedSkills, setSelectedSkills] = useState( @@ -250,8 +258,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar setEditPublished(agent.published); setEditIsPrivate(agent.is_private); setSelectedSkills(agent.skills ?? []); + setEditCommonQuestions(agent.common_questions ?? []); setSkillSearch(""); }, [ + agent.common_questions, agent.description, agent.is_private, agent.name, @@ -269,6 +279,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar // include `soul`) keep working. if (agentDetail) { setEditSoul(agentDetail.soul ?? ""); + setEditCommonQuestions(agentDetail.common_questions ?? []); } else { setEditSoul(agent.soul ?? ""); } @@ -345,6 +356,9 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar published: editIsPrivate ? false : editPublished, is_private: editIsPrivate, skills: selectedSkills, + common_questions: editCommonQuestions + .map((q) => q.trim()) + .filter((q) => q.length > 0), }, }); toast.success("智能体已更新"); @@ -831,7 +845,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar 编辑智能体:{desensitize(agent.name)} - 可以修改名称、描述、技能白名单、SOUL 内容与发布状态。 + 可以修改名称、描述、技能白名单、SOUL 内容、常用问题与发布状态。
@@ -935,6 +949,52 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar className="min-h-40 font-mono text-xs" />
+
+ +

+ 这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。 +

+
+ {editCommonQuestions.map((value, index) => ( +
+ { + const next = [...editCommonQuestions]; + next[index] = e.currentTarget.value; + setEditCommonQuestions(next); + }} + className="h-9 text-sm" + /> + +
+ ))} + +
+

公开发布

diff --git a/frontend-web/src/core/knowledge/api.ts b/frontend-web/src/core/knowledge/api.ts index bc4ce250..45cd196e 100644 --- a/frontend-web/src/core/knowledge/api.ts +++ b/frontend-web/src/core/knowledge/api.ts @@ -127,6 +127,30 @@ export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) { ); } +export interface IngestFileResult { + note: KnowledgeNote; + created: boolean; + notes: KnowledgeNote[]; + llm_fallback: boolean; +} + +/** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */ +export async function ingestKnowledgeFile( + file: File, + opts: { title?: string; tags?: string; status?: string; model_name?: string | null } = {}, +) { + const form = new FormData(); + form.append("file", file); + if (opts.title) form.append("title", opts.title); + if (opts.tags) form.append("tags", opts.tags); + if (opts.status) form.append("status", opts.status); + if (opts.model_name) form.append("model_name", opts.model_name); + // No Content-Type header — the browser sets the multipart boundary itself. + return parseResponse( + await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }), + ); +} + /** Resolve `[[wikilink]]` targets to note ids / vault pages. */ export async function resolveWikilinks(targets: string[]) { const res = await parseResponse<{ resolved: WikilinkResolutionMap }>( diff --git a/frontend-web/src/core/knowledge/hooks.ts b/frontend-web/src/core/knowledge/hooks.ts index b3a9b9fb..8f40e309 100644 --- a/frontend-web/src/core/knowledge/hooks.ts +++ b/frontend-web/src/core/knowledge/hooks.ts @@ -11,6 +11,7 @@ import { exportKnowledgeGraph, fetchVaultPage, getKnowledgeNote, + ingestKnowledgeFile, ingestThread, ingestThreadsBatch, listDuplicates, @@ -106,6 +107,16 @@ export function useKnowledgeActions() { mutationFn: ingestThreadsBatch, onSuccess: invalidate, }), + ingestFile: useMutation({ + mutationFn: ({ + file, + opts, + }: { + file: File; + opts?: Parameters[1]; + }) => ingestKnowledgeFile(file, opts), + onSuccess: invalidate, + }), exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }), merge: useMutation({ mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) => diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 1cf155c2..38a3a3e0 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -13,11 +13,7 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ExportTrigger } from "@/components/workspace/export-trigger"; import { InputBox } from "@/components/workspace/input-box"; -import { - MessageList, - MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, - MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM, -} from "@/components/workspace/messages"; +import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; @@ -32,7 +28,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(); @@ -163,146 +158,156 @@ export default function AgentChatPage() { await thread.stop(); }, [thread]); - const messageListPaddingBottom = showFollowups - ? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM + - MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM - : undefined; const isLandingState = isNewThread && !hasStartedConversation; + const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; + + const status = thread.error + ? "error" + : thread.isLoading + ? "streaming" + : "ready"; return ( -
-
- {/* Agent badge */} -
- - - {agentDisplayName} - -
- -
- -
-
- - - - - - -
-
+ {agentDisplayName} + +
-
-
- handleSubmit({ text, files: [] })} - /> -
+
+ +
-
-
-
-
-
-
+
+ + + + + + +
+ -
+ {isLandingState ? ( + /* ── home: centered hero + suggested questions + input ────── + Everything lives inside the scrollable main column. No fixed, + no absolute, no viewport centering — so the avatar can't touch + the header and the input can't cover the sidebar. */ +
+ handleSubmit({ text, files: [] })} + disabled={isDemoMode} + className="mb-7" + /> +
- handleSubmit({ text, files: [] }) - } - disabled={ - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" - } - /> - ) + disabled={isDemoMode} + onContextChange={(context) => + setSettings("context", context) } - disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} - onContextChange={(context) => setSettings("context", context)} onFollowupsVisibilityChange={setShowFollowups} onSubmit={handleSubmit} onStop={handleStop} /> + {isDemoMode && ( +
+ {t.common.notAvailableInDemoMode} +
+ )}
- {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( -
- {t.common.notAvailableInDemoMode} -
- )}
-
+ ) : ( + /* ── conversation (active / history): messages scroll, input + docked at the bottom of the main column only. */ +
+ handleSubmit({ text, files: [] })} + /> + +
+
+
+
+
+ )}
+ {/* ── Right: read-only agent profile sidebar ───────────────────── */} handleSubmit({ text, files: [] })} />
diff --git a/frontend-web/src/pages/KnowledgeBasePage.tsx b/frontend-web/src/pages/KnowledgeBasePage.tsx index 59c2a3e0..167fa6e5 100644 --- a/frontend-web/src/pages/KnowledgeBasePage.tsx +++ b/frontend-web/src/pages/KnowledgeBasePage.tsx @@ -11,6 +11,7 @@ import { Search, Sparkles, Trash2, + Upload, X, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -323,6 +324,119 @@ function BatchImportDialog({ ); } +function ImportFileDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const { ingestFile } = useKnowledgeActions(); + const navigate = useNavigate(); + const [file, setFile] = useState(null); + const [title, setTitle] = useState(""); + const [tags, setTags] = useState(""); + const [model, setModel] = useState(null); + + const reset = () => { + setFile(null); + setTitle(""); + setTags(""); + setModel(null); + }; + + const handleImport = async () => { + if (!file) { + toast.error("请选择文件"); + return; + } + try { + const result = await ingestFile.mutateAsync({ + file, + opts: { + title: title.trim() || undefined, + tags: tags.trim() || undefined, + status: "approved", + model_name: model, + }, + }); + const count = result.notes?.length ?? 1; + if (result.llm_fallback) { + // The model produced nothing usable — the file was stored verbatim as a + // draft instead of distilled into a wiki note. + toast.warning( + `已导入 ${count} 条(大模型异常,改用规则抽取并存为草稿)`, + ); + } else { + toast.success(`已从文件生成 ${count} 条知识`); + } + onOpenChange(false); + reset(); + if (result.note?.id) { + navigate(`/page/workspace/knowledge/${result.note.id}`); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "文件导入失败"); + } + }; + + return ( + + + + 导入文件生成 Wiki + + 上传文档(Markdown / TXT / PDF / Word / PPT / Excel),自动提炼为知识库 wiki 词条并写入 vault。 + + +
+ + setTitle(e.target.value)} + /> + setTags(e.target.value)} + /> + +
+ + + + +
+
+ ); +} + function DedupDialog({ open, onOpenChange, @@ -698,6 +812,7 @@ export default function KnowledgeBasePage() { const [status, setStatus] = useState("all"); const [createOpen, setCreateOpen] = useState(false); const [batchOpen, setBatchOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [dedupOpen, setDedupOpen] = useState(false); const params = useMemo( @@ -752,6 +867,9 @@ export default function KnowledgeBasePage() {
+ @@ -814,6 +932,7 @@ export default function KnowledgeBasePage() {
+
diff --git a/frontend-web/src/pages/KnowledgeGraphPage.tsx b/frontend-web/src/pages/KnowledgeGraphPage.tsx index 4cacc1a9..93a16b86 100644 --- a/frontend-web/src/pages/KnowledgeGraphPage.tsx +++ b/frontend-web/src/pages/KnowledgeGraphPage.tsx @@ -4,13 +4,17 @@ import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { useTheme } from "@/contexts/ThemeContext"; import { knowledgeExportFileUrl } from "@/core/knowledge/api"; import { useKnowledgeActions } from "@/core/knowledge/hooks"; export default function KnowledgeGraphPage() { const navigate = useNavigate(); + const { theme } = useTheme(); const { exportGraph } = useKnowledgeActions(); - const [src, setSrc] = useState(null); + // Keep the served base URL (without the theme) so a theme toggle can re-skin + // the iframe without regenerating the graph on the backend. + const [baseSrc, setBaseSrc] = useState(null); const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null); const regenerate = useCallback(async () => { @@ -18,7 +22,7 @@ export default function KnowledgeGraphPage() { const result = await exportGraph.mutateAsync(); setStats(result.stats); // Cache-bust the iframe so it reflects the freshly generated graph. - setSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`); + setBaseSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`); } catch (error) { toast.error(error instanceof Error ? error.message : "生成图谱失败"); } @@ -29,6 +33,10 @@ export default function KnowledgeGraphPage() { void regenerate(); }, [regenerate]); + // The graph HTML lives in an iframe and can't read the app's CSS theme, so we + // pass the active day/night mode via a query param; toggling re-renders it. + const src = baseSrc ? `${baseSrc}&theme=${theme}` : null; + return (
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index cab36879..6b0e6b8d 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -250,7 +250,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/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); `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/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); `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. @@ -290,7 +290,7 @@ Self-contained product knowledge base. **All knowledge capability lives in this - **DB mirror** — tables `knowledge_notes` / `knowledge_sources` / `knowledge_tags` / `knowledge_note_tags` (`knowledge/models.py`, registered in `persistence/models/__init__.py`, auto-created by `Base.metadata.create_all`; portable types `PortableLongText`/`BeijingDateTime`). Powers listing/filter/search/audit. Global/shared — no `user_id` column; `created_by`/`updated_by` are audit-only (resolved via `get_effective_user_id()`). - **Markdown vault** — obsidian-wiki compatible, default `{$DEER_FLOW_HOME}/data/knowledge/obsidian-vault`. `ObsidianWikiAdapter` (`obsidian_adapter.py`) writes wiki-capture frontmatter+body, and after **every** write syncs `index.md` / `log.md` (CAPTURE/INGEST/UPDATE/ARCHIVE/EXPORT) / `hot.md` / `.manifest.json` (source-hash dedup for `skip_existing`). `markdown_vault.py` handles frontmatter build/parse, slugging, wikilink extraction and **path-traversal-proof** resolution. -**Module layout:** `models.py` (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) · `repository.py` (DB only, no LLM) · `schemas.py` (NoteDraft/SourceDraft DTOs) · `skill_templates.py` · `markdown_vault.py` · `manifest.py` · `extractor.py` (rule-based fallback) · `llm_extractor.py` (**default**: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to `extractor.py`) · `sources.py` · `exporter.py` (`graph.json`/`graph.graphml`/`cypher.txt` + self-contained vanilla-JS `graph.html`; entity nodes + relation edges) · `chunking.py` + `embeddings.py` (OpenAI-compatible embedding client, keyword fallback) + `search.py` (keyword/vector/hybrid) · `sensitive.py` (secret/PII redaction) · `dedup.py` (Jaccard + hash dedup) · `auto_ingest.py` (background queue/worker) · `obsidian_adapter.py` · `service.py` (`KnowledgeService` orchestrator — the only thing the router touches). Tests: `tests/test_knowledge.py` (20 cases). Frontend: `frontend-web/src/core/knowledge/*` + `pages/KnowledgeBasePage.tsx` / `KnowledgeGraphPage.tsx` + chat `knowledge-save-trigger.tsx` / `knowledge-rag-toggle.tsx`; spec at `frontend-web/docs/product-knowledge-base-dev.md`. +**Module layout:** `models.py` (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) · `repository.py` (DB only, no LLM) · `schemas.py` (NoteDraft/SourceDraft DTOs) · `skill_templates.py` · `markdown_vault.py` · `manifest.py` · `extractor.py` (rule-based fallback) · `llm_extractor.py` (**default**: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to `extractor.py`) · `sources.py` · `exporter.py` (`graph.json`/`graph.graphml`/`cypher.txt` + self-contained vanilla-JS `graph.html`; entity nodes + relation edges. **`graph.html` is theme-aware** — it reads `?theme=light|dark` from its own URL since the embedding iframe can't inherit the app's CSS; `KnowledgeGraphPage.tsx` appends the active day/night mode and re-skins on toggle. The layout cools down and **freezes** so dense graphs stay clickable, node radius scales with degree, and hovering/clicking a node focuses it + its direct neighbours while dimming the rest + a detail panel — taming heavy node/edge clutter) · `chunking.py` + `embeddings.py` (OpenAI-compatible embedding client, keyword fallback) + `search.py` (keyword/vector/hybrid) · `sensitive.py` (secret/PII redaction) · `dedup.py` (Jaccard + hash dedup) · `auto_ingest.py` (background queue/worker) · `obsidian_adapter.py` · `service.py` (`KnowledgeService` orchestrator — the only thing the router touches; capture entry points `capture_thread` / `capture_search` / `capture_file` / `create_manual_note`, all converging on `_persist_draft`). Tests: `tests/test_knowledge.py` (20 cases). Frontend: `frontend-web/src/core/knowledge/*` + `pages/KnowledgeBasePage.tsx` / `KnowledgeGraphPage.tsx` + chat `knowledge-save-trigger.tsx` / `knowledge-rag-toggle.tsx`; spec at `frontend-web/docs/product-knowledge-base-dev.md`. **Wikilinks & multi-note capture** (`knowledge.{related_notes_enabled,related_notes_limit,multi_note_enabled}`, all default-on; `project_name` defaults to `zncm`): - **Navigable `[[wikilinks]]`** — `service.resolve_wikilink(s)` maps a `[[target]]` to a knowledge note (by id → `vault_path` → title) or a vault meta page (`read_vault_page`, path-traversal-proof), else `missing`. `markdown_vault.normalize_wikilink_target` canonicalizes targets (drops `|alias`, slashes, `.md`). The frontend `MarkdownRenderer` renders non-numeric `[[...]]` as clickable links from the resolver map (numeric `[[123]]` stay citation badges); note links navigate, vault links open a `VaultPageDialog`, missing links are greyed. diff --git a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py index 9512f0f9..7b8e4e29 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py +++ b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py @@ -13,9 +13,12 @@ from __future__ import annotations import logging import mimetypes +import re +import tempfile +from pathlib import Path from typing import Any -from fastapi import APIRouter, BackgroundTasks, HTTPException, Request +from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field @@ -26,6 +29,12 @@ from deerflow.knowledge.service import EmptyThreadError from deerflow.runtime import serialize_channel_values from deerflow.runtime.references import build_reference_batches from deerflow.runtime.user_context import get_effective_user_id +from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown + +# Plain-text/markdown files are read directly; binary documents are converted. +_TEXT_EXTENSIONS = {".md", ".markdown", ".txt", ".text", ""} +# Bound the in-memory read so a knowledge import can't OOM the gateway. +_MAX_IMPORT_BYTES = 25 * 1024 * 1024 logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/knowledge", tags=["knowledge"]) @@ -299,6 +308,80 @@ async def ingest_threads_batch(body: BatchIngestRequest, request: Request) -> di return {"ok": True, "processed": len(items), "created": created, "skipped": skipped, "failed": failed, "llm_fallback": llm_fallback, "items": items} +async def _extract_upload_text(file: UploadFile) -> tuple[str, str]: + """Read an uploaded file and return ``(filename, markdown_text)``. + + Plain-text/markdown is decoded directly; PDF/Office documents are converted + to Markdown via the shared converter. Raises ``HTTPException`` on empty / + oversized / unsupported / unparseable files. + """ + name = file.filename or "document" + ext = Path(name).suffix.lower() + raw = await file.read() + if not raw: + raise HTTPException(status_code=400, detail="文件为空") + if len(raw) > _MAX_IMPORT_BYTES: + raise HTTPException(status_code=413, detail=f"文件过大,最大支持 {_MAX_IMPORT_BYTES // (1024 * 1024)}MB") + + if ext in _TEXT_EXTENSIONS: + text = raw.decode("utf-8", errors="ignore") + elif ext in CONVERTIBLE_EXTENSIONS: + # Convert in an isolated temp dir; a fixed safe name avoids path issues + # while preserving the extension so the converter detects the type. + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) / f"import{ext}" + tmp_path.write_bytes(raw) + md_path = await convert_file_to_markdown(tmp_path) + if md_path is None: + raise HTTPException(status_code=422, detail=f"无法解析文件:{name}") + text = md_path.read_text(encoding="utf-8", errors="ignore") + else: + raise HTTPException(status_code=415, detail=f"不支持的文件类型:{ext or '未知'}(支持 md/txt/pdf/word/ppt/excel)") + + if not text.strip(): + raise HTTPException(status_code=422, detail="文件内容为空或无法提取文本") + return name, text + + +@router.post("/ingest/file") +@require_auth +async def ingest_file( + request: Request, + file: UploadFile = File(...), + title: str | None = Form(default=None), + tags: str | None = Form(default=None), + status: str = Form(default="approved"), + model_name: str | None = Form(default=None), +) -> dict[str, Any]: + """Import an uploaded file into the knowledge base as wiki note(s). + + Converts the file to Markdown, distills it via the wiki-capture LLM pipeline + (same as thread capture), and writes the resulting declarative note(s) into + the shared vault. ``tags`` is a comma/space-separated string. + """ + service = get_knowledge_service(request) + name, text = await _extract_upload_text(file) + tag_list = [t for t in re.split(r"[,,\s]+", tags or "") if t] + try: + result = await service.capture_file( + content_md=text, + filename=name, + title=(title or None), + tags=tag_list, + status=status, + created_by=get_effective_user_id(), + model_name=(model_name or None), + ) + except EmptyThreadError: + raise HTTPException(status_code=422, detail="文件内容无可沉淀的知识") + return { + "note": result["note"], + "created": result["created"], + "notes": result.get("notes", [result["note"]]), + "llm_fallback": result.get("llm_fallback", False), + } + + @router.post("/ingest/search") @require_auth async def ingest_search(body: IngestSearchRequest, request: Request) -> dict[str, Any]: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/exporter.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/exporter.py index 2f91a5ee..2153df8c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/exporter.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/exporter.py @@ -135,7 +135,15 @@ def to_cypher(graph: dict[str, Any]) -> str: def to_html(graph: dict[str, Any]) -> str: - """Self-contained force-directed graph (canvas + vanilla JS, no CDN).""" + """Self-contained, theme-aware force-directed graph (canvas + vanilla JS, no CDN). + + The page reads ``?theme=light|dark`` from its own URL so the embedding iframe + can stay in sync with the app's day/night mode (an iframe cannot inherit the + parent's CSS). The layout settles and then **freezes** so a dense graph stops + drifting and stays clickable; node size scales with degree; and hovering or + clicking a node focuses it + its direct neighbours while dimming everything + else, which cuts through heavy node/edge clutter. + """ data_json = json.dumps(graph, ensure_ascii=False) return _HTML_TEMPLATE.replace("__GRAPH_DATA__", data_json) @@ -178,50 +186,246 @@ _HTML_TEMPLATE = """ Knowledge Graph -
-
+
+
+
知识笔记
+
实体
+
+
+
×

+
重置视图
""" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py index 5c86f697..e9d924a2 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py @@ -84,6 +84,46 @@ references(外部来源汇总)、decision(架构/设计决策)、journal }""" +_DOC_SYSTEM_PROMPT = """\ +你是知识库整理专家,遵循 obsidian-wiki 的 wiki-capture 规范,把一篇**文档**提炼成\ +**陈述式知识(declarative knowledge)**,而不是照抄原文。 + +要求: +- 只保留高价值内容:核心结论、关键事实、定义、流程、数据、决策与其依据。 +- 丢弃:目录、页眉页脚、版权声明、重复的样板文字、无意义的排版噪声。 +- 用自己的话重组为结构化知识,不要逐段复制原文。 +- 推断内容用 ^[inferred] 标记,不确定/冲突用 ^[ambiguous] 标记。 +- 分类 category 取值之一:synthesis(分析/方案/结论)、concepts(概念/框架)、\ +references(外部来源汇总)、decision(架构/设计决策)、journal(纪要)。 +- 用中文输出。 + +一篇文档可能只讲一个主题,也可能覆盖多个相互独立的主题。请把它拆成 1~4 页知识: +- 默认只产出 **1 页**;只有当文档明确包含**多个彼此独立、可各自成文**的主题时才拆成多页。 +- 每一页聚焦单一主题,标题与 summary 必须彼此区分。 +- 文档没有可沉淀的知识时返回空的 notes 数组。 + +只输出一个 JSON 对象,不要任何额外文字或 Markdown 代码块,结构与对话提炼一致: +{ + "should_ingest": true, + "reason": "为什么值得/不值得沉淀", + "notes": [ + { + "title": "简洁的知识标题(不超过 40 字)", + "summary": "1-2 句话概括这页知识", + "category": "synthesis", + "context": "文档背景或适用场景,1-3 句", + "findings": ["结论/要点(陈述式,可含 ^[inferred])", "..."], + "reasoning": "为什么成立、取舍、不确定性", + "implications": ["后续建议/风险/下一步", "..."], + "tags": ["标签1", "标签2"], + "entities": [{"name": "实体名", "type": "technology|concept|person|org|product|other"}], + "relations": [{"from": "实体A", "to": "实体B", "type": "uses|relates_to|part_of|depends_on"}], + "confidence": 0.0 + } + ] +}""" + + def _build_transcript(turns: list[dict[str, str]], sources: list[SourceDraft], *, max_chars: int) -> str: parts: list[str] = [] for i, t in enumerate(turns, 1): @@ -171,21 +211,27 @@ def _notes_from_data(data: dict[str, Any]) -> list[dict[str, Any]]: def _build_note_draft( note_data: dict[str, Any], *, - thread_id: str, - thread_title: str | None, - turns: list[dict[str, str]], + source_type: str, + source_id: str | None, + source_key: str, + fallback_title: str, title_override: str | None, tags: list[str] | None, project_name: str | None, sources: list[SourceDraft], - source_key_suffix: str = "", ) -> NoteDraft: - """Render one note dict into a :class:`NoteDraft`.""" + """Render one note dict into a :class:`NoteDraft`. + + Source-agnostic: the caller supplies ``source_type`` / ``source_id`` / + ``source_key`` and a ``fallback_title`` (used when neither the user override + nor the LLM produced a usable title), so both thread and document capture + share one renderer. + """ category = str(note_data.get("category") or "synthesis").strip().lower() if category not in _VALID_CATEGORIES: category = "synthesis" - final_title = (title_override or "").strip() or str(note_data.get("title") or "").strip() or extractor._auto_title(thread_title, turns) + final_title = (title_override or "").strip() or str(note_data.get("title") or "").strip() or fallback_title summary = str(note_data.get("summary") or "").strip() related = extractor.related_links(project_name) body = _render_body(note_data, final_title, related, sources) @@ -210,15 +256,15 @@ def _build_note_draft( summary=summary or final_title, content_md=body, category=category, - source_type="thread", - source_id=thread_id, + source_type=source_type, + source_id=source_id, tags=base_tags, confidence=max(0.0, min(confidence, 1.0)), related=related, sources=sources, entities=[e for e in entities if isinstance(e, dict) and e.get("name")], relations=[r for r in relations if isinstance(r, dict) and r.get("from") and r.get("to")], - source_key=f"thread:{thread_id}{source_key_suffix}", + source_key=source_key, ) @@ -293,6 +339,7 @@ async def extract_thread_llm_multi( else: note_dicts = note_dicts[: max(1, max_notes)] + fallback_title = extractor._auto_title(thread_title, turns) drafts: list[NoteDraft] = [] for i, nd in enumerate(note_dicts): # The user-supplied title override only applies to a single-note result; @@ -302,14 +349,100 @@ async def extract_thread_llm_multi( drafts.append( _build_note_draft( nd, - thread_id=thread_id, - thread_title=thread_title, - turns=turns, + source_type="thread", + source_id=thread_id, + source_key=f"thread:{thread_id}{suffix}", + fallback_title=fallback_title, + title_override=override, + tags=tags, + project_name=project_name, + sources=sources, + ) + ) + return drafts + + +async def extract_document_llm_multi( + text: str, + *, + doc_id: str, + doc_title: str | None = None, + title: str | None = None, + tags: list[str] | None = None, + project_name: str | None = "zncm", + sources: list[SourceDraft] | None = None, + model_name: str | None = None, + max_input_chars: int = 12000, + allow_multi: bool = True, + max_notes: int = 4, +) -> list[NoteDraft]: + """Distill an imported **document** into one *or several* knowledge notes. + + Mirrors :func:`extract_thread_llm_multi` but takes raw document text (already + converted to Markdown/plain text) instead of a conversation. Returns ``[]`` + when the LLM path fails (callers fall back to a verbatim note); raises + :class:`LLMRejectedIngest` when the model deliberately judges the document + worthless. Resulting notes carry ``source_type="file"``. + """ + text = (text or "").strip() + if not text: + return [] + sources = sources or [] + + try: + from langchain_core.messages import HumanMessage, SystemMessage + + from deerflow.models import create_chat_model + + model = create_chat_model(name=model_name or None, thinking_enabled=False) + body = text if len(text) <= max_input_chars else text[:max_input_chars] + "\n…(已截断)" + header = f"文档标题:{doc_title}\n\n" if doc_title else "" + prompt = [ + SystemMessage(content=_DOC_SYSTEM_PROMPT + "\n\n" + WIKI_CAPTURE_GUIDANCE), + HumanMessage(content=f"以下是一篇文档,请按规范提炼为知识 JSON:\n\n{header}{body}"), + ] + response = await model.ainvoke(prompt) + raw = response.content if isinstance(response.content, str) else str(response.content) + data = _parse_json(raw) + except Exception: + logger.exception("[knowledge] document %s — LLM extraction call failed; using rule-based fallback", doc_id) + return [] + + if not data: + logger.warning("[knowledge] document %s — LLM returned no parsable JSON; using rule-based fallback", doc_id) + return [] + + note_dicts = _notes_from_data(data) + if not note_dicts: + reason = str(data.get("reason") or "")[:200] + if _is_falsey(data.get("should_ingest")): + logger.warning("[knowledge] document %s SKIPPED — model judged not worth ingesting: %s", doc_id, reason) + raise LLMRejectedIngest(reason or "no knowledge value") + logger.warning( + "[knowledge] document %s — LLM produced no notes (should_ingest=%s, reason=%r); using rule-based fallback", + doc_id, + data.get("should_ingest"), + reason, + ) + return [] + note_dicts = note_dicts[:1] if not allow_multi else note_dicts[: max(1, max_notes)] + + fallback_title = (doc_title or title or "文档知识").strip() or "文档知识" + drafts: list[NoteDraft] = [] + for i, nd in enumerate(note_dicts): + override = title if (len(note_dicts) == 1) else None + suffix = "" if len(note_dicts) == 1 else f"#{i}" + drafts.append( + _build_note_draft( + nd, + source_type="file", + source_id=doc_id, + source_key=f"file:{doc_id}{suffix}", + fallback_title=fallback_title, title_override=override, tags=tags, project_name=project_name, sources=sources, - source_key_suffix=suffix, ) ) return drafts 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 d1fbe3f9..71d5f245 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py @@ -394,6 +394,129 @@ class KnowledgeService: ) return await self._persist_draft(draft, created_by=created_by) + def _verbatim_file_draft(self, body: str, filename: str, *, title: str | None, tags: list[str] | None, doc_id: str) -> NoteDraft: + """Build a verbatim fallback note from a file when LLM distillation fails. + + Lands as a ``draft`` (like the thread fallback) since it is the raw + document, not distilled knowledge. + """ + base_tags = list(tags or []) + for default in (self.project_name, "knowledge-base"): + if default and default not in base_tags: + base_tags.append(default) + name = (title or Path(filename).stem or "文档知识").strip() or "文档知识" + summary = next((ln.strip() for ln in body.splitlines() if ln.strip()), name)[:280] + return NoteDraft( + title=name, + summary=summary, + content_md=body, + category="references", + source_type="file", + source_id=doc_id, + status="draft", + tags=base_tags, + confidence=0.4, + related=extractor.related_links(self.project_name), + source_key=f"file:{doc_id}", + ) + + async def capture_file( + self, + *, + content_md: str, + filename: str, + title: str | None = None, + tags: list[str] | None = None, + status: str = "approved", + created_by: str | None = None, + model_name: str | None = None, + skip_existing: bool = False, + ) -> dict[str, Any]: + """Import a file (already converted to Markdown/text) into knowledge notes. + + Runs the same wiki-capture LLM distillation as thread capture, so an + uploaded document becomes one or more declarative wiki notes in the vault. + Falls back to a verbatim ``draft`` note when the LLM is disabled or returns + nothing usable. Returns ``{"note", "created", "notes", "llm_fallback"}``; + raises :class:`EmptyThreadError` when there is nothing to keep. + """ + import hashlib + + text = (content_md or "").strip() + if not text: + raise EmptyThreadError(f"File {filename} is empty") + # Strip any frontmatter the document carries; the vault writer adds its own. + _, parsed = MarkdownVault.parse(text) + body = (parsed or text).strip() + if not body: + raise EmptyThreadError(f"File {filename} has no body content") + # Stable id from name + size so re-importing the same file can be skipped. + doc_id = hashlib.sha1(f"{filename}:{len(body)}".encode("utf-8")).hexdigest()[:16] + + logger.info( + "[knowledge] importing file '%s' (chars=%d, doc_id=%s, llm_extract=%s, skip_existing=%s)", + filename, + len(body), + doc_id, + self.config.llm_extract_enabled, + skip_existing, + ) + + if skip_existing: + existing = await self.repo.find_by_source("file", doc_id) + if existing: + logger.info("[knowledge] file '%s' already imported (note %s); skipping", filename, existing.get("id")) + return {"note": existing, "created": False, "notes": [existing], "llm_fallback": False} + + drafts: list[NoteDraft] = [] + llm_fallback = False + if self.config.llm_extract_enabled: + try: + drafts = await llm_extractor.extract_document_llm_multi( + body, + doc_id=doc_id, + doc_title=title or Path(filename).stem, + title=title, + tags=tags, + project_name=self.project_name, + model_name=model_name or self.config.extract_model_name, + max_input_chars=self.config.extract_max_input_chars, + allow_multi=self.config.multi_note_enabled, + ) + except llm_extractor.LLMRejectedIngest as exc: + raise EmptyThreadError(f"File {filename} judged not worth ingesting: {exc}") from exc + if not drafts: + llm_fallback = True + if not drafts: + drafts = [self._verbatim_file_draft(body, filename, title=title, tags=tags, doc_id=doc_id)] + + effective_status = status + if llm_fallback and normalize_status(status) == "approved": + effective_status = "draft" + normalized = normalize_status(effective_status) + for draft in drafts: + draft.status = normalized + + # Cross-link sibling notes split from the same document (resolved by title + # once all are persisted), mirroring multi-note thread capture. + if len(drafts) > 1: + for i, draft in enumerate(drafts): + siblings = [f"[[{o.title}]]" for j, o in enumerate(drafts) if j != i and o.title] + if siblings: + draft.content_md = _inject_related_notes(draft.content_md, siblings) + draft.related = list(dict.fromkeys([*siblings, *draft.related])) + + notes = [await self._persist_draft(draft, created_by=created_by) for draft in drafts] + logger.info( + "[knowledge] file '%s' imported → %d note(s) [%s], status=%s, llm_fallback=%s", + filename, + len(notes), + ", ".join(n.get("title", "") for n in notes), + normalized, + llm_fallback, + ) + return {"note": notes[0], "created": True, "notes": notes, "llm_fallback": llm_fallback} + # -- CRUD ------------------------------------------------------------- async def list_notes( self, From f0aec4cd7ffa4644afa92e1522f7bda4f9731292 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 11:37:33 +0800 Subject: [PATCH 08/23] =?UTF-8?q?feat(square):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=B9=BF=E5=9C=BA=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93/=E6=8A=80=E8=83=BD=E6=8C=89?= =?UTF-8?q?=E5=B9=BF=E5=9C=BA=E5=88=86=E7=B1=BB=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 PublishSquare/PublishSquaresSettings 配置与读写接口 - agents/skills 增加 square_id 字段及对应迁移脚本 - 前端新增发布广场设置页、技能转移选择器,画廊按广场分 Tab 展示 - lead_agent prompt 及若干持久化层适配调整 Co-Authored-By: Claude Opus 4.8 (1M context) --- batch-distill/distill_people.py | 37 +- .../workspace/agent-info-sidebar.tsx | 48 +- .../components/workspace/agent-welcome.tsx | 12 +- .../workspace/agents/agent-card.tsx | 223 +++++---- .../workspace/agents/agent-gallery.tsx | 69 ++- .../agents/skill-transfer-selector.tsx | 429 ++++++++++++++++++ .../src/components/workspace/input-box.tsx | 21 +- .../settings/appearance-settings-page.tsx | 3 + .../settings/skill-settings-page.tsx | 90 +++- frontend-web/src/core/agents/api.ts | 3 + frontend-web/src/core/agents/hooks.ts | 5 + frontend-web/src/core/agents/types.ts | 47 +- frontend-web/src/core/skills/api.ts | 9 +- frontend-web/src/core/skills/hooks.ts | 4 +- frontend-web/src/core/skills/type.ts | 2 + frontend-web/src/core/system-settings/api.ts | 46 ++ .../src/core/system-settings/hooks.ts | 29 ++ frontend-web/src/pages/AgentChatPage.tsx | 1 + .../src/pages/PublishSquaresSettingsPage.tsx | 193 ++++++++ frontend-web/src/pages/WorkspaceRoutes.tsx | 13 + offline-backend-20260512/backend/CLAUDE.md | 6 + .../backend/app/gateway/routers/agents.py | 52 ++- .../backend/app/gateway/routers/skills.py | 30 +- .../app/gateway/routers/system_settings.py | 86 ++++ .../deerflow/agents/lead_agent/agent.py | 39 +- .../deerflow/agents/lead_agent/prompt.py | 121 +++++ .../harness/deerflow/config/agents_config.py | 32 +- .../deerflow/config/system_settings.py | 70 +++ .../harness/deerflow/knowledge/exporter.py | 14 +- .../deerflow/persistence/agents/base.py | 2 + .../deerflow/persistence/agents/memory.py | 19 +- .../deerflow/persistence/agents/model.py | 40 +- .../deerflow/persistence/agents/sql.py | 17 +- .../20260611_01_agents_skills_square_id.py | 74 +++ .../deerflow/persistence/skills/memory.py | 5 + .../deerflow/persistence/skills/model.py | 4 + .../deerflow/persistence/skills/sql.py | 5 + .../scripts/sql/column_additions_mysql.sql | 11 +- .../backend/tests/test_agents_backend.py | 22 +- 39 files changed, 1707 insertions(+), 226 deletions(-) create mode 100644 frontend-web/src/components/workspace/agents/skill-transfer-selector.tsx create mode 100644 frontend-web/src/pages/PublishSquaresSettingsPage.tsx create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_01_agents_skills_square_id.py diff --git a/batch-distill/distill_people.py b/batch-distill/distill_people.py index d5c4d171..a57d9609 100644 --- a/batch-distill/distill_people.py +++ b/batch-distill/distill_people.py @@ -320,12 +320,36 @@ def _extract_text(payload) -> str: def _clean_markdown(text: str) -> str: - """去掉 agent 可能套上的 ```markdown ... ``` 围栏。""" + """去掉 agent 可能套上的 ```markdown ... ``` 围栏,并规整 YAML frontmatter。""" t = text.strip() m = re.match(r"^```(?:markdown|md)?\s*\n(.*)\n```$", t, re.S) if m: t = m.group(1).strip() - return t + return _normalize_frontmatter(t) + + +def _normalize_frontmatter(text: str) -> str: + """修复模型常见的 frontmatter 跑偏,让 YAML 能正常解析。 + + 模型经常把字段名写成 markdown 加粗(``**name:**``)、加空行、留行尾双空格, + 这些都会让 frontmatter 解析失败。这里把开头 ``---`` 到下一个 ``---`` 之间的内容 + 规整为干净的 ``key: value`` 形式。正文(第二个 ``---`` 之后)保持原样。 + """ + if not text.startswith("---"): + return text + # 拆出 frontmatter 块:开头 --- 与下一行单独的 --- 之间 + m = re.match(r"^---[ \t]*\n(.*?)\n---[ \t]*\n?(.*)$", text, re.S) + if not m: + return text + body_fm, rest = m.group(1), m.group(2) + out_lines = [] + for line in body_fm.splitlines(): + s = line.rstrip() # 去行尾双空格 + s = s.replace("**", "") # 去 markdown 加粗标记 + if not s.strip(): + continue # 丢掉空行 + out_lines.append(s) + return "---\n" + "\n".join(out_lines) + "\n---\n\n" + rest.lstrip("\n") # ================================ 名单读取 ================================ @@ -454,6 +478,13 @@ def main(): if not read_people(excel): sys.exit(f"名单为空或不存在:{excel}\n请放 people.xlsx(第一列写人名)。") + # --redo:一次性清掉名单里这些人的旧记录,让他们重做一遍; + # 之后本次会话内跑成功的会正常计为「已完成」,循环才会往后推进 + # (否则每轮 pending() 都把已完成的人当未完成,永远卡在第 1 个人) + if args.redo: + for p in read_people(excel): + manifest.pop(p["name"], None) + client = DeerFlowClient(BASE_URL) if not client.login(): sys.exit("登录失败。请检查 BASE_URL 是否指向正在运行的服务、口令是否正确。") @@ -469,7 +500,7 @@ def main(): out = [] for p in people: r = manifest.get(p["name"], {}) - done = (not args.redo) and r.get("status") == "succeeded" and (OUTPUT_DIR / f"{slugify(p['name'])}.md").exists() + done = r.get("status") == "succeeded" and (OUTPUT_DIR / f"{slugify(p['name'])}.md").exists() if not done: out.append(p) return out diff --git a/frontend-web/src/components/workspace/agent-info-sidebar.tsx b/frontend-web/src/components/workspace/agent-info-sidebar.tsx index b9f03351..71facd9f 100644 --- a/frontend-web/src/components/workspace/agent-info-sidebar.tsx +++ b/frontend-web/src/components/workspace/agent-info-sidebar.tsx @@ -13,11 +13,40 @@ import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { Tooltip } from "@/components/workspace/tooltip"; -import { type Agent } from "@/core/agents"; +import { type Agent, normalizeAgentQuestions } from "@/core/agents"; import { useSkills } from "@/core/skills/hooks"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; +/** + * Turn the admin-authored SOUL.md markdown into a clean, readable profile blurb + * for the narrow sidebar. The sidebar is a profile card, not a document view — + * so we strip markdown syntax (headings like `###`, emphasis `**`/`*`, inline + * code, list bullets, blockquotes) rather than rendering it, while keeping the + * paragraph/line structure intact. + */ +function cleanProfileText(raw: string): string { + return raw + .split("\n") + .map((line) => + line + // Drop heading markers (`#`, `##`, `### …`) — the main "###不要出现" fix. + .replace(/^\s{0,3}#{1,6}\s*/, "") + // Blockquote markers. + .replace(/^\s{0,3}>\s?/, "") + // List bullets `- ` / `* ` / `+ ` → a bullet glyph. + .replace(/^(\s*)[-*+]\s+/, "$1• ") + // Bold / italic / inline-code markers. + .replace(/(\*\*|__)(.*?)\1/g, "$2") + .replace(/(\*|_)(.*?)\1/g, "$2") + .replace(/`([^`]+)`/g, "$1"), + ) + .join("\n") + // Collapse 3+ blank lines into a single blank line. + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + /** * Collapsible right-hand "智能体简介" panel on the agent chat page. It is a * read-only window onto the agent's profile — avatar, name, slogan/description, @@ -50,7 +79,9 @@ export function AgentInfoSidebar({ const description = agent?.description ? desensitize(agent.description) : undefined; - const soul = agent?.soul?.trim() ? desensitize(agent.soul) : undefined; + const soul = agent?.soul?.trim() + ? cleanProfileText(desensitize(agent.soul)) + : undefined; // Map a stored skill name to its Chinese display label (falls back to name). const skillLabel = useMemo(() => { @@ -62,7 +93,7 @@ export function AgentInfoSidebar({ }, [skills]); const questions = useMemo( - () => (agent?.common_questions ?? []).filter((q) => q.trim().length > 0), + () => normalizeAgentQuestions(agent?.common_questions), [agent?.common_questions], ); @@ -116,10 +147,11 @@ export function AgentInfoSidebar({ )}
- {/* Soul — the agent's long-form personality blurb. */} + {/* Description — the agent's long-form blurb, sourced from SOUL.md but + stripped of markdown syntax so it reads as plain profile copy. */} {soul && (
-

Soul

+

描述

{soul}

@@ -165,14 +197,14 @@ export function AgentInfoSidebar({ key={index} type="button" disabled={disabled} - onClick={() => onAskQuestion(question)} - title={question} + onClick={() => onAskQuestion(question.prompt)} + title={desensitize(question.prompt)} className={cn( "border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full rounded-xl border px-3 py-2.5 text-left text-sm leading-relaxed transition-colors", "disabled:cursor-not-allowed disabled:opacity-50", )} > - {desensitize(question)} + {desensitize(question.title)} ))}
diff --git a/frontend-web/src/components/workspace/agent-welcome.tsx b/frontend-web/src/components/workspace/agent-welcome.tsx index 84f53aaa..66578cea 100644 --- a/frontend-web/src/components/workspace/agent-welcome.tsx +++ b/frontend-web/src/components/workspace/agent-welcome.tsx @@ -2,7 +2,7 @@ import { BotIcon } from "lucide-react"; -import { type Agent } from "@/core/agents"; +import { type Agent, normalizeAgentQuestions } from "@/core/agents"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -30,9 +30,7 @@ export function AgentWelcome({ const description = agent?.description ? desensitize(agent.description) : undefined; - const questions = (agent?.common_questions ?? []).filter( - (q) => q.trim().length > 0, - ); + const questions = normalizeAgentQuestions(agent?.common_questions); return (
onAskQuestion(question)} - title={question} + onClick={() => onAskQuestion(question.prompt)} + title={desensitize(question.prompt)} className={cn( "border-border/70 bg-card/60 hover:bg-accent hover:border-border max-w-[280px] truncate rounded-full border px-4 py-2 text-sm transition-colors", "disabled:cursor-not-allowed disabled:opacity-50", )} > - {desensitize(question)} + {desensitize(question.title)} ))}
diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 427710cb..b506826a 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -4,12 +4,10 @@ import { BotIcon, CopyIcon, GlobeIcon, - InfoIcon, LockIcon, MessageSquareIcon, PinIcon, PlusIcon, - SearchIcon, ShieldIcon, SparklesIcon, SquarePenIcon, @@ -49,12 +47,22 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; +import { usePublishSquares } from "@/core/system-settings/hooks"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; +import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector"; import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { + normalizeAgentQuestions, useAgent, useCreateAgent, useDeleteAgent, @@ -65,7 +73,7 @@ import { useUpdateAgent, } from "@/core/agents"; import { getAgent } from "@/core/agents/api"; -import type { Agent } from "@/core/agents"; +import type { Agent, AgentQuestion } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; import { useSkills } from "@/core/skills/hooks"; @@ -210,17 +218,18 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar const [editDescription, setEditDescription] = useState(agent.description ?? ""); const [editSoul, setEditSoul] = useState(agent.soul ?? ""); // Common questions (常用问题) are authored here in the edit dialog and read - // back read-only on the agent chat page. Keep one trailing blank row so there - // is always an empty input to type the next question into. - const [editCommonQuestions, setEditCommonQuestions] = useState( - agent.common_questions ?? [], + // back read-only on the agent chat page. Each row has a short title (shown on + // the chip) and the full prompt sent when clicked. + const [editCommonQuestions, setEditCommonQuestions] = useState( + () => normalizeAgentQuestions(agent.common_questions), ); const [editPublished, setEditPublished] = useState(agent.published); const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private); + const [editSquareId, setEditSquareId] = useState(agent.square_id ?? ""); + const { data: publishSquares } = usePublishSquares(); const [selectedSkills, setSelectedSkills] = useState( agent.skills ?? [], ); - const [skillSearch, setSkillSearch] = useState(""); const [detailSkillName, setDetailSkillName] = useState(null); // The /api/agents list response intentionally omits `soul` (perf optimization @@ -257,15 +266,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar setEditDescription(agent.description ?? ""); setEditPublished(agent.published); setEditIsPrivate(agent.is_private); + setEditSquareId(agent.square_id ?? ""); setSelectedSkills(agent.skills ?? []); - setEditCommonQuestions(agent.common_questions ?? []); - setSkillSearch(""); + setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions)); }, [ agent.common_questions, agent.description, agent.is_private, agent.name, agent.published, + agent.square_id, agent.skills, editOpen, ]); @@ -279,7 +289,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar // include `soul`) keep working. if (agentDetail) { setEditSoul(agentDetail.soul ?? ""); - setEditCommonQuestions(agentDetail.common_questions ?? []); + setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions)); } else { setEditSoul(agent.soul ?? ""); } @@ -355,10 +365,14 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar // backend will see, regardless of any stale state in editPublished. published: editIsPrivate ? false : editPublished, is_private: editIsPrivate, + square_id: editSquareId, skills: selectedSkills, common_questions: editCommonQuestions - .map((q) => q.trim()) - .filter((q) => q.length > 0), + .map((q) => ({ + title: q.title.trim() || q.prompt.trim(), + prompt: q.prompt.trim() || q.title.trim(), + })) + .filter((q) => q.prompt.length > 0), }, }); toast.success("智能体已更新"); @@ -448,34 +462,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar } } - function toggleSkill(skillName: string) { - setSelectedSkills((prev) => { - if (prev.includes(skillName)) { - return prev.filter((name) => name !== skillName); - } - return [...prev, skillName]; - }); - } - const enabledSkills = useMemo( () => skills.filter((skill) => skill.enabled), [skills], ); - // Search over English name, Chinese name (name_zh) and description, so both - // 中英文 work. Already-selected skills stay searchable too (selection is - // independent of this filter). - const filteredSkills = useMemo(() => { - const q = skillSearch.trim().toLowerCase(); - if (!q) return enabledSkills; - return enabledSkills.filter( - (skill) => - skill.name.toLowerCase().includes(q) || - (skill.name_zh ?? "").toLowerCase().includes(q) || - (skill.description ?? "").toLowerCase().includes(q), - ); - }, [enabledSkills, skillSearch]); - // Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by // both the card header and the compact list row. const statusBadges = ( @@ -873,65 +864,13 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar 已选 {selectedSkills.length} 个
- {enabledSkills.length > 0 && ( -
- - setSkillSearch(e.currentTarget.value)} - className="h-8 pl-7 text-xs" - /> -
- )} -
- {isSkillsLoading ? ( -

技能加载中...

- ) : enabledSkills.length === 0 ? ( -

- 当前没有可用技能 -

- ) : filteredSkills.length === 0 ? ( -

没有匹配的技能

- ) : ( -
- {filteredSkills.map((skill) => { - const checked = selectedSkills.includes(skill.name); - return ( -
- - -
- ); - })} -
- )} -
+

不选任何技能会保存为无技能(skills = [])。

@@ -954,32 +893,53 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar

这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。

-
- {editCommonQuestions.map((value, index) => ( -
- + {editCommonQuestions.map((item, index) => ( +
+
+ { + const next = [...editCommonQuestions]; + next[index] = { + ...next[index]!, + title: e.currentTarget.value, + }; + setEditCommonQuestions(next); + }} + className="h-8 text-sm font-medium" + /> + +
+