Initial commit: hardened DeerFlow factory

Vendored deer-flow upstream (bytedance/deer-flow) plus prompt-injection
hardening:

- New deerflow.security package: content_delimiter, html_cleaner,
  sanitizer (8 layers — invisible chars, control chars, symbols, NFC,
  PUA, tag chars, horizontal whitespace collapse with newline/tab
  preservation, length cap)
- New deerflow.community.searx package: web_search, web_fetch,
  image_search backed by a private SearX instance, every external
  string sanitized and wrapped in <<<EXTERNAL_UNTRUSTED_CONTENT>>>
  delimiters
- All native community web providers (ddg_search, tavily, exa,
  firecrawl, jina_ai, infoquest, image_search) replaced with hard-fail
  stubs that raise NativeWebToolDisabledError at import time, so a
  misconfigured tool.use path fails loud rather than silently falling
  back to unsanitized output
- Native client back-doors (jina_client.py, infoquest_client.py)
  stubbed too
- Native-tool tests quarantined under tests/_disabled_native/
  (collect_ignore_glob via local conftest.py)
- Sanitizer Layer 7 fix: only collapse horizontal whitespace, preserve
  newlines and tabs so list/table structure survives
- Hardened runtime config.yaml references only the searx-backed tools
- Factory overlay (backend/) kept in sync with deer-flow tree as a
  reference / source

See HARDENING.md for the full audit trail and verification steps.
This commit is contained in:
2026-04-12 14:23:57 +02:00
commit 6de0bf9f5b
889 changed files with 173052 additions and 0 deletions

View File

@@ -0,0 +1,939 @@
# 代码更改总结(按文件 diff细到每一行
基于 `git diff HEAD` 的完整 diff按文件列出所有变更。删除/新增文件单独说明。
---
## 一、后端
### 1. `backend/CLAUDE.md`
```diff
@@ -156,7 +156,7 @@ FastAPI application on port 8001 with health check at `GET /health`.
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
-| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; `?download=true` for download with citation removal |
+| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; `?download=true` for file download |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
```
- **第 159 行**:表格中 Artifacts 描述由「download with citation removal」改为「file download」。
---
### 2. `backend/packages/harness/deerflow/agents/lead_agent/prompt.py`
```diff
@@ -240,34 +240,8 @@ You have access to skills that provide optimized workflows for specific tasks. E
- Action-Oriented: Focus on delivering results, not explaining processes
</response_style>
-<citations_format>
-After web_search, ALWAYS include citations in your output:
-
-1. Start with a `<citations>` block in JSONL format listing all sources
-2. In content, use FULL markdown link format: [Short Title](full_url)
-
-**CRITICAL - Citation Link Format:**
-- CORRECT: `[TechCrunch](https://techcrunch.com/ai-trends)` - full markdown link with URL
-- WRONG: `[arXiv:2502.19166]` - missing URL, will NOT render as link
-- WRONG: `[Source]` - missing URL, will NOT render as link
-
-**Rules:**
-- Every citation MUST be a complete markdown link with URL: `[Title](https://...)`
-- Write content naturally, add citation link at end of sentence/paragraph
-- NEVER use bare brackets like `[arXiv:xxx]` or `[Source]` without URL
-
-**Example:**
-<citations>
-{{"id": "cite-1", "title": "AI Trends 2026", "url": "https://techcrunch.com/ai-trends", "snippet": "Tech industry predictions"}}
-{{"id": "cite-2", "title": "OpenAI Research", "url": "https://openai.com/research", "snippet": "Latest AI research developments"}}
-</citations>
-The key AI trends for 2026 include enhanced reasoning capabilities and multimodal integration [TechCrunch](https://techcrunch.com/ai-trends). Recent breakthroughs in language models have also accelerated progress [OpenAI](https://openai.com/research).
-</citations_format>
-
-
<critical_reminders>
- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess
-- **Web search citations**: When you use web_search (or synthesize subagent results that used it), you MUST output the `<citations>` block and [Title](url) links as specified in citations_format so citations display for the user.
{subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks.
```
```diff
@@ -341,7 +315,6 @@ def apply_prompt_template(subagent_enabled: bool = False) -> str:
# Add subagent reminder to critical_reminders if enabled
subagent_reminder = (
"- **Orchestrator Mode**: You are a task orchestrator - decompose complex tasks into parallel sub-tasks and launch multiple subagents simultaneously. Synthesize results, don't execute directly.\n"
- "- **Citations when synthesizing**: When you synthesize subagent results that used web search or cite sources, you MUST include a consolidated `<citations>` block (JSONL format) and use [Title](url) markdown links in your response so citations display correctly.\n"
if subagent_enabled
else ""
)
```
- **删除**`<citations_format>...</citations_format>` 整段(原约 243266 行、critical_reminders 中「Web search citations」一条、`apply_prompt_template` 中「Citations when synthesizing」一行。
---
### 3. `backend/app/gateway/routers/artifacts.py`
```diff
@@ -1,12 +1,10 @@
-import json
import mimetypes
-import re
import zipfile
from pathlib import Path
from urllib.parse import quote
-from fastapi import APIRouter, HTTPException, Request, Response
-from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
from app.gateway.path_utils import resolve_thread_virtual_path
```
- **第 1 行**:删除 `import json`
- **第 3 行**:删除 `import re`
- **第 67 行**`fastapi` 中去掉 `Response``fastapi.responses` 中增加 `Response`(保留二进制 inline 返回用)。
```diff
@@ -24,40 +22,6 @@ def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
return False
-def _extract_citation_urls(content: str) -> set[str]:
- """Extract URLs from <citations> JSONL blocks. Format must match frontend core/citations/utils.ts."""
- urls: set[str] = set()
- for match in re.finditer(r"<citations>([\s\S]*?)</citations>", content):
- for line in match.group(1).split("\n"):
- line = line.strip()
- if line.startswith("{"):
- try:
- obj = json.loads(line)
- if "url" in obj:
- urls.add(obj["url"])
- except (json.JSONDecodeError, ValueError):
- pass
- return urls
-
-
-def remove_citations_block(content: str) -> str:
- """Remove ALL citations from markdown (blocks, [cite-N], and citation links). Used for downloads."""
- if not content:
- return content
-
- citation_urls = _extract_citation_urls(content)
-
- result = re.sub(r"<citations>[\s\S]*?</citations>", "", content)
- if "<citations>" in result:
- result = re.sub(r"<citations>[\s\S]*$", "", result)
- result = re.sub(r"\[cite-\d+\]", "", result)
-
- for url in citation_urls:
- result = re.sub(rf"\[[^\]]+\]\({re.escape(url)}\)", "", result)
-
- return re.sub(r"\n{3,}", "\n\n", result).strip()
-
-
def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None:
```
- **删除**`_extract_citation_urls``remove_citations_block` 两个函数(约 2562 行)。
```diff
@@ -172,24 +136,9 @@ async def get_artifact(thread_id: str, path: str, request: Request) -> FileRespo
# Encode filename for Content-Disposition header (RFC 5987)
encoded_filename = quote(actual_path.name)
-
- # Check if this is a markdown file that might contain citations
- is_markdown = mime_type == "text/markdown" or actual_path.suffix.lower() in [".md", ".markdown"]
-
+
# if `download` query parameter is true, return the file as a download
if request.query_params.get("download"):
- # For markdown files, remove citations block before download
- if is_markdown:
- content = actual_path.read_text()
- clean_content = remove_citations_block(content)
- return Response(
- content=clean_content.encode("utf-8"),
- media_type="text/markdown",
- headers={
- "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
- "Content-Type": "text/markdown; charset=utf-8"
- }
- )
return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"})
if mime_type and mime_type == "text/html":
```
- **删除**`is_markdown` 判断及「markdown 时读文件 + remove_citations_block + Response」分支download 时统一走 `FileResponse`
---
### 4. `backend/packages/harness/deerflow/subagents/builtins/general_purpose.py`
```diff
@@ -24,21 +24,10 @@ Do NOT use for simple, single-step operations.""",
- Do NOT ask for clarification - work with the information provided
</guidelines>
-<citations_format>
-If you used web_search (or similar) and cite sources, ALWAYS include citations in your output:
-1. Start with a `<citations>` block in JSONL format listing all sources (one JSON object per line)
-2. In content, use FULL markdown link format: [Short Title](full_url)
-- Every citation MUST be a complete markdown link with URL: [Title](https://...)
-- Example block:
-<citations>
-{"id": "cite-1", "title": "...", "url": "https://...", "snippet": "..."}
-</citations>
-</citations_format>
-
<output_format>
When you complete the task, provide:
1. A brief summary of what was accomplished
-2. Key findings or results (with citation links when from web search)
+2. Key findings or results
3. Any relevant file paths, data, or artifacts created
4. Issues encountered (if any)
</output_format>
```
- **删除**`<citations_format>...</citations_format>` 整段。
- **第 40 行**:第 2 条由「Key findings or results (with citation links when from web search)」改为「Key findings or results」。
---
## 二、前端文档与工具
### 5. `frontend/AGENTS.md`
```diff
@@ -49,7 +49,6 @@ src/
├── core/ # Core business logic
│ ├── api/ # API client & data fetching
│ ├── artifacts/ # Artifact management
-│ ├── citations/ # Citation handling
│ ├── config/ # App configuration
│ ├── i18n/ # Internationalization
```
- **第 52 行**:删除目录树中的 `citations/` 一行。
---
### 6. `frontend/CLAUDE.md`
```diff
@@ -30,7 +30,7 @@ Frontend (Next.js) ──▶ LangGraph SDK ──▶ LangGraph Backend (lead_age
└── Tools & Skills
```
-The frontend is a stateful chat application. Users create **threads** (conversations), send messages, and receive streamed AI responses. The backend orchestrates agents that can produce **artifacts** (files/code), **todos**, and **citations**.
+The frontend is a stateful chat application. Users create **threads** (conversations), send messages, and receive streamed AI responses. The backend orchestrates agents that can produce **artifacts** (files/code) and **todos**.
### Source Layout (`src/`)
```
- **第 33 行**「and **citations**」删除。
---
### 7. `frontend/README.md`
```diff
@@ -89,7 +89,6 @@ src/
├── core/ # Core business logic
│ ├── api/ # API client & data fetching
│ ├── artifacts/ # Artifact management
-│ ├── citations/ # Citation handling
│ ├── config/ # App configuration
│ ├── i18n/ # Internationalization
```
- **第 92 行**:删除目录树中的 `citations/` 一行。
---
### 8. `frontend/src/lib/utils.ts`
```diff
@@ -8,5 +8,5 @@ export function cn(...inputs: ClassValue[]) {
/** Shared class for external links (underline by default). */
export const externalLinkClass =
"text-primary underline underline-offset-2 hover:no-underline";
-/** For streaming / loading state when link may be a citation (no underline). */
+/** Link style without underline by default (e.g. for streaming/loading). */
export const externalLinkClassNoUnderline = "text-primary hover:underline";
```
- **第 11 行**:仅注释修改,导出值未变。
---
## 三、前端组件
### 9. `frontend/src/components/workspace/artifacts/artifact-file-detail.tsx`
```diff
@@ -8,7 +8,6 @@ import {
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
-import * as React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
...
@@ -21,7 +20,6 @@ import (
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
-import { createCitationMarkdownComponents } from "@/components/ai-elements/inline-citation";
import { Select, SelectItem } from "@/components/ui/select";
...
@@ -33,12 +31,6 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
-import type { Citation } from "@/core/citations";
-import {
- contentWithoutCitationsFromParsed,
- removeAllCitations,
- useParsedCitations,
-} from "@/core/citations";
import { useI18n } from "@/core/i18n/hooks";
...
@@ -48,9 +40,6 @@ import { cn } from "@/lib/utils";
import { Tooltip } from "../tooltip";
-import { SafeCitationContent } from "../messages/safe-citation-content";
-import { useThread } from "../messages/context";
-
import { useArtifacts } from "./context";
```
```diff
@@ -92,22 +81,13 @@ export function ArtifactFileDetail({
const previewable = useMemo(() => {
return (language === "html" && !isWriteFile) || language === "markdown";
}, [isWriteFile, language]);
- const { thread } = useThread();
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
- const parsed = useParsedCitations(
- language === "markdown" ? (content ?? "") : "",
- );
- const cleanContent =
- language === "markdown" && content ? parsed.cleanContent : (content ?? "");
- const contentWithoutCitations =
- language === "markdown" && content
- ? contentWithoutCitationsFromParsed(parsed)
- : (content ?? "");
+ const displayContent = content ?? "";
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
```
```diff
@@ -219,7 +199,7 @@ export function ArtifactFileDetail({
disabled={!content}
onClick={async () => {
try {
- await navigator.clipboard.writeText(contentWithoutCitations ?? "");
+ await navigator.clipboard.writeText(displayContent ?? "");
toast.success(t.clipboard.copiedToClipboard);
...
@@ -255,27 +235,17 @@ export function ArtifactFileDetail({
viewMode === "preview" &&
language === "markdown" &&
content && (
- <SafeCitationContent
- content={content}
- isLoading={thread.isLoading}
- rehypePlugins={streamdownPlugins.rehypePlugins}
- className="flex size-full items-center justify-center p-4 my-0"
- renderBody={(p) => (
- <ArtifactFilePreview
- filepath={filepath}
- threadId={threadId}
- content={content}
- language={language ?? "text"}
- cleanContent={p.cleanContent}
- citationMap={p.citationMap}
- />
- )}
+ <ArtifactFilePreview
+ filepath={filepath}
+ threadId={threadId}
+ content={displayContent}
+ language={language ?? "text"}
/>
)}
{isCodeFile && viewMode === "code" && (
<CodeEditor
className="size-full resize-none rounded-none border-none"
- value={cleanContent ?? ""}
+ value={displayContent ?? ""}
readonly
/>
)}
```
```diff
@@ -295,29 +265,17 @@ export function ArtifactFilePreview({
threadId,
content,
language,
- cleanContent,
- citationMap,
}: {
filepath: string;
threadId: string;
content: string;
language: string;
- cleanContent: string;
- citationMap: Map<string, Citation>;
}) {
if (language === "markdown") {
- const components = createCitationMarkdownComponents({
- citationMap,
- syntheticExternal: true,
- });
return (
<div className="size-full px-4">
- <Streamdown
- className="size-full"
- {...streamdownPlugins}
- components={components}
- >
- {cleanContent ?? ""}
+ <Streamdown className="size-full" {...streamdownPlugins}>
+ {content ?? ""}
</Streamdown>
</div>
);
```
- 删除React 命名空间、inline-citation、core/citations、SafeCitationContent、useThreadparsed/cleanContent/contentWithoutCitations 及引用解析逻辑。
- 新增:`displayContent = content ?? ""`预览与复制、CodeEditor 均使用 `displayContent``ArtifactFilePreview` 仅保留 `content`/`language` 等,去掉 `cleanContent`/`citationMap``createCitationMarkdownComponents`
---
### 10. `frontend/src/components/workspace/messages/message-group.tsx`
```diff
@@ -39,9 +39,7 @@ import { useArtifacts } from "../artifacts";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
-import { useThread } from "./context";
-
-import { SafeCitationContent } from "./safe-citation-content";
+import { MarkdownContent } from "./markdown-content";
export function MessageGroup({
```
```diff
@@ -120,7 +118,7 @@ export function MessageGroup({
<ChainOfThoughtStep
key={step.id}
label={
- <SafeCitationContent
+ <MarkdownContent
content={step.reasoning ?? ""}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
@@ -128,12 +126,7 @@ export function MessageGroup({
}
></ChainOfThoughtStep>
) : (
- <ToolCall
- key={step.id}
- {...step}
- isLoading={isLoading}
- rehypePlugins={rehypePlugins}
- />
+ <ToolCall key={step.id} {...step} isLoading={isLoading} />
),
)}
{lastToolCallStep && (
@@ -143,7 +136,6 @@ export function MessageGroup({
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
- rehypePlugins={rehypePlugins}
/>
</FlipDisplay>
)}
@@ -178,7 +170,7 @@ export function MessageGroup({
<ChainOfThoughtStep
key={lastReasoningStep.id}
label={
- <SafeCitationContent
+ <MarkdownContent
content={lastReasoningStep.reasoning ?? ""}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
@@ -201,7 +193,6 @@ function ToolCall({
result,
isLast = false,
isLoading = false,
- rehypePlugins,
}: {
id?: string;
messageId?: string;
@@ -210,15 +201,10 @@ function ToolCall({
result?: string | Record<string, unknown>;
isLast?: boolean;
isLoading?: boolean;
- rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const { t } = useI18n();
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
useArtifacts();
- const { thread } = useThread();
- const threadIsLoading = thread.isLoading;
-
- const fileContent = typeof args.content === "string" ? args.content : "";
if (name === "web_search") {
```
```diff
@@ -364,42 +350,27 @@ function ToolCall({
}, 100);
}
- const isMarkdown =
- path?.toLowerCase().endsWith(".md") ||
- path?.toLowerCase().endsWith(".markdown");
-
return (
- <>
- <ChainOfThoughtStep
- key={id}
- className="cursor-pointer"
- label={description}
- icon={NotebookPenIcon}
- onClick={() => {
- select(
- new URL(
- `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
- ).toString(),
- );
- setOpen(true);
- }}
- >
- {path && (
- <ChainOfThoughtSearchResult className="cursor-pointer">
- {path}
- </ChainOfThoughtSearchResult>
- )}
- </ChainOfThoughtStep>
- {isMarkdown && (
- <SafeCitationContent
- content={fileContent}
- isLoading={threadIsLoading && isLast}
- rehypePlugins={rehypePlugins}
- loadingOnly
- className="mt-2 ml-8"
- />
+ <ChainOfThoughtStep
+ key={id}
+ className="cursor-pointer"
+ label={description}
+ icon={NotebookPenIcon}
+ onClick={() => {
+ select(
+ new URL(
+ `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
+ ).toString(),
+ );
+ setOpen(true);
+ }}
+ >
+ {path && (
+ <ChainOfThoughtSearchResult className="cursor-pointer">
+ {path}
+ </ChainOfThoughtSearchResult>
)}
- </>
+ </ChainOfThoughtStep>
);
} else if (name === "bash") {
```
- 两处 `SafeCitationContent``MarkdownContent`ToolCall 去掉 `rehypePlugins` 及内部 `useThread`/`fileContent`write_file 分支去掉 markdown 预览块(`isMarkdown` + `SafeCitationContent`),仅保留 `ChainOfThoughtStep` + path。
---
### 11. `frontend/src/components/workspace/messages/message-list-item.tsx`
```diff
@@ -12,7 +12,6 @@ import {
} from "@/components/ai-elements/message";
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
-import { removeAllCitations } from "@/core/citations";
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
@@ -24,7 +23,7 @@ import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button";
-import { SafeCitationContent } from "./safe-citation-content";
+import { MarkdownContent } from "./markdown-content";
...
@@ -54,11 +53,11 @@ export function MessageListItem({
>
<div className="flex gap-1">
<CopyButton
- clipboardData={removeAllCitations(
+ clipboardData={
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
""
- )}
+ }
/>
</div>
</MessageToolbar>
@@ -154,7 +153,7 @@ function MessageContent_({
return (
<AIElementMessageContent className={className}>
{filesList}
- <SafeCitationContent
+ <MarkdownContent
content={contentToParse}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
```
- 删除 `removeAllCitations``SafeCitationContent` 引用;复制改为原始内容;渲染改为 `MarkdownContent`
---
### 12. `frontend/src/components/workspace/messages/message-list.tsx`
```diff
@@ -26,7 +26,7 @@ import { StreamingIndicator } from "../streaming-indicator";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
-import { SafeCitationContent } from "./safe-citation-content";
+import { MarkdownContent } from "./markdown-content";
import { MessageListSkeleton } from "./skeleton";
...
@@ -69,7 +69,7 @@ export function MessageList({
const message = group.messages[0];
if (message && hasContent(message)) {
return (
- <SafeCitationContent
+ <MarkdownContent
key={group.id}
content={extractContentFromMessage(message)}
isLoading={thread.isLoading}
@@ -89,7 +89,7 @@ export function MessageList({
return (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
- <SafeCitationContent
+ <MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
rehypePlugins={rehypePlugins}
```
- 三处import 与两处渲染均由 `SafeCitationContent` 改为 `MarkdownContent`props 不变。
---
### 13. `frontend/src/components/workspace/messages/subtask-card.tsx`
```diff
@@ -29,7 +29,7 @@ import { cn } from "@/lib/utils";
import { FlipDisplay } from "../flip-display";
-import { SafeCitationContent } from "./safe-citation-content";
+import { MarkdownContent } from "./markdown-content";
...
@@ -153,7 +153,7 @@ export function SubtaskCard({
<ChainOfThoughtStep
label={
task.result ? (
- <SafeCitationContent
+ <MarkdownContent
content={task.result}
isLoading={false}
rehypePlugins={rehypePlugins}
```
- import 与一处渲染:`SafeCitationContent``MarkdownContent`
---
### 14. 新增 `frontend/src/components/workspace/messages/markdown-content.tsx`
(当前工作区新增,未在 git 中)
```ts
"use client";
import type { ImgHTMLAttributes } from "react";
import type { ReactNode } from "react";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
isHuman?: boolean;
img?: (props: ImgHTMLAttributes<HTMLImageElement> & { threadId?: string; maxWidth?: string }) => ReactNode;
};
/** Renders markdown content. */
export function MarkdownContent({
content,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
img,
}: MarkdownContentProps) {
if (!content) return null;
const components = img ? { img } : undefined;
return (
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</MessageResponse>
);
}
```
- 纯 Markdown 渲染组件,无引用解析或 loading 占位逻辑。
---
### 15. 删除 `frontend/src/components/workspace/messages/safe-citation-content.tsx`
- 原约 85 行提供引用解析、loading、renderBody/loadingOnly、cleanContent/citationMap。已由 `MarkdownContent` 替代,整文件删除。
---
### 16. 删除 `frontend/src/components/ai-elements/inline-citation.tsx`
- 原约 289 行;提供 `createCitationMarkdownComponents` 等,用于将 `[cite-N]`/URL 渲染为可点击引用。仅被 artifact 预览使用,已移除后整文件删除。
---
## 四、前端 core
### 17. 删除 `frontend/src/core/citations/index.ts`
- 原 13 行,导出:`contentWithoutCitationsFromParsed``extractDomainFromUrl``isExternalUrl``parseCitations``removeAllCitations``shouldShowCitationLoading``syntheticCitationFromLink``useParsedCitations`、类型 `Citation`/`ParseCitationsResult`/`UseParsedCitationsResult`。整文件删除。
---
### 18. 删除 `frontend/src/core/citations/use-parsed-citations.ts`
- 原 28 行,`useParsedCitations(content)``UseParsedCitationsResult`。整文件删除。
---
### 19. 删除 `frontend/src/core/citations/utils.ts`
- 原 226 行,解析 `<citations>`/`[cite-N]`、buildCitationMap、removeAllCitations、contentWithoutCitationsFromParsed 等。整文件删除。
---
### 20. `frontend/src/core/i18n/locales/types.ts`
```diff
@@ -115,12 +115,6 @@ export interface Translations {
startConversation: string;
};
- // Citations
- citations: {
- loadingCitations: string;
- loadingCitationsWithCount: (count: number) => string;
- };
-
// Chats
chats: {
```
- 删除 `Translations.citations` 及其两个字段。
---
### 21. `frontend/src/core/i18n/locales/zh-CN.ts`
```diff
@@ -164,12 +164,6 @@ export const zhCN: Translations = {
startConversation: "开始新的对话以查看消息",
},
- // Citations
- citations: {
- loadingCitations: "正在整理引用...",
- loadingCitationsWithCount: (count: number) => `正在整理 ${count} 个引用...`,
- },
-
// Chats
chats: {
```
- 删除 `citations` 命名空间。
---
### 22. `frontend/src/core/i18n/locales/en-US.ts`
```diff
@@ -167,13 +167,6 @@ export const enUS: Translations = {
startConversation: "Start a conversation to see messages here",
},
- // Citations
- citations: {
- loadingCitations: "Organizing citations...",
- loadingCitationsWithCount: (count: number) =>
- `Organizing ${count} citation${count === 1 ? "" : "s"}...`,
- },
-
// Chats
chats: {
```
- 删除 `citations` 命名空间。
---
## 五、技能与 Demo
### 23. `skills/public/github-deep-research/SKILL.md`
```diff
@@ -147,5 +147,5 @@ Save report as: `research_{topic}_{YYYYMMDD}.md`
3. **Triangulate claims** - 2+ independent sources
4. **Note conflicting info** - Don't hide contradictions
5. **Distinguish fact vs opinion** - Label speculation clearly
-6. **Cite inline** - Reference sources near claims
+6. **Reference sources** - Add source references near claims where applicable
7. **Update as you go** - Don't wait until end to synthesize
```
- 第 150 行:一条措辞修改。
---
### 24. `skills/public/market-analysis/SKILL.md`
```diff
@@ -15,7 +15,7 @@ This skill generates professional, consulting-grade market analysis reports in M
- Follow the **"Visual Anchor → Data Contrast → Integrated Analysis"** flow per sub-chapter
- Produce insights following the **"Data → User Psychology → Strategy Implication"** chain
- Embed pre-generated charts and construct comparison tables
-- Generate inline citations formatted per **GB/T 7714-2015** standards
+- Include references formatted per **GB/T 7714-2015** where applicable
- Output reports entirely in Chinese with professional consulting tone
...
@@ -36,7 +36,7 @@ The skill expects the following inputs from the upstream agentic workflow:
| **Analysis Framework Outline** | Defines the logic flow and general topics for the report | Yes |
| **Data Summary** | The source of truth containing raw numbers and metrics | Yes |
| **Chart Files** | Local file paths for pre-generated chart images | Yes |
-| **External Search Findings** | URLs and summaries for inline citations | Optional |
+| **External Search Findings** | URLs and summaries for inline references | Optional |
...
@@ -87,7 +87,7 @@ The report **MUST NOT** stop after the Conclusion — it **MUST** include Refere
- **Tone**: McKinsey/BCG — Authoritative, Objective, Professional
- **Language**: All headings and content strictly in **Chinese**
- **Number Formatting**: Use English commas for thousands separators (`1,000` not `1000`)
-- **Data Citation**: **Bold** important viewpoints and key numbers
+- **Data emphasis**: **Bold** important viewpoints and key numbers
...
@@ -109,11 +109,9 @@ Every insight must connect **Data → User Psychology → Strategy Implication**
treating male audiences only as a secondary gift-giving segment."
```
-### Citations & References
-- **Inline**: Use `[\[Index\]](URL)` format (e.g., `[\[1\]](https://example.com)`)
-- **Placement**: Append citations at the end of sentences using information from External Search Findings
-- **Index Assignment**: Sequential starting from **1** based on order of appearance
-- **References Section**: Formatted strictly per **GB/T 7714-2015**
+### References
+- **Inline**: Use markdown links for sources (e.g. `[Source Title](URL)`) when using External Search Findings
+- **References section**: Formatted strictly per **GB/T 7714-2015**
...
@@ -183,7 +181,7 @@ Before considering the report complete, verify:
- [ ] All headings are in Chinese with proper numbering (no "Chapter/Part/Section")
- [ ] Charts are embedded with `![Description](path)` syntax
- [ ] Numbers use English commas for thousands separators
-- [ ] Inline citations use `[\[N\]](URL)` format
+- [ ] Inline references use markdown links where applicable
- [ ] References section follows GB/T 7714-2015
```
- 多处核心能力、输入表、Data Citation、Citations & References 小节与检查项改为「references / 引用」表述并去掉 `[\[N\]](URL)` 格式要求。
---
### 25. `frontend/public/demo/threads/.../user-data/outputs/research_deerflow_20260201.md`
```diff
@@ -1,12 +1,3 @@
-<citations>
-{"id": "cite-1", "title": "DeerFlow GitHub Repository", "url": "https://github.com/bytedance/deer-flow", "snippet": "..."}
-...(共 7 条 JSONL
-</citations>
# DeerFlow Deep Research Report
- **Research Date:** 2026-02-01
```
- 删除文件开头的 `<citations>...</citations>` 整块9 行),正文从 `# DeerFlow Deep Research Report` 开始。
---
### 26. `frontend/public/demo/threads/.../thread.json`
- **主要变更**:某条 `write_file``args.content` 中,将原来的「`<citations>...\n</citations>\n# DeerFlow Deep Research Report\n\n...`」改为「`# DeerFlow Deep Research Report\n\n...`」,即去掉 `<citations>...</citations>` 块,保留其后全文。
- **其他**:一处 `present_files``filepaths` 由单行数组改为多行格式;文件末尾增加/统一换行。
- 消息顺序、结构及其他字段未改。
---
## 六、统计
| 项目 | 数量 |
|------|------|
| 修改文件 | 18 |
| 新增文件 | 1markdown-content.tsx |
| 删除文件 | 5safe-citation-content.tsx, inline-citation.tsx, core/citations/* 共 3 个) |
| 总行数变化 | +62 / -894diff stat |
以上为按文件、细到每一行 diff 的代码更改总结。

View File

@@ -0,0 +1,865 @@
# 技能名称冲突修复 - 代码改动文档
## 概述
本文档详细记录了修复 public skill 和 custom skill 同名冲突问题的所有代码改动。
**状态**: ⚠️ **已知问题保留** - 同名技能冲突问题已识别但暂时保留,后续版本修复
**日期**: 2026-02-10
---
## 问题描述
### 原始问题
当 public skill 和 custom skill 有相同名称(但技能文件内容不同)时,会出现以下问题:
1. **打开冲突**: 打开 public skill 时,同名的 custom skill 也会被打开
2. **关闭冲突**: 关闭 public skill 时,同名的 custom skill 也会被关闭
3. **配置冲突**: 两个技能共享同一个配置键,导致状态互相影响
### 根本原因
- 配置文件中技能状态仅使用 `skill_name` 作为键
- 同名但不同类别的技能无法区分
- 缺少类别级别的重复检查
---
## 解决方案
### 核心思路
1. **组合键存储**: 使用 `{category}:{name}` 格式作为配置键,确保唯一性
2. **向后兼容**: 保持对旧格式(仅 `name`)的支持
3. **重复检查**: 在加载时检查每个类别内是否有重复的技能名称
4. **API 增强**: API 支持可选的 `category` 查询参数来区分同名技能
### 设计原则
- ✅ 最小改动原则
- ✅ 向后兼容
- ✅ 清晰的错误提示
- ✅ 代码复用(提取公共函数)
---
## 详细代码改动
### 一、后端配置层 (`backend/packages/harness/deerflow/config/extensions_config.py`)
#### 1.1 新增方法: `get_skill_key()`
**位置**: 第 152-166 行
**代码**:
```python
@staticmethod
def get_skill_key(skill_name: str, skill_category: str) -> str:
"""Get the key for a skill in the configuration.
Uses format '{category}:{name}' to uniquely identify skills,
allowing public and custom skills with the same name to coexist.
Args:
skill_name: Name of the skill
skill_category: Category of the skill ('public' or 'custom')
Returns:
The skill key in format '{category}:{name}'
"""
return f"{skill_category}:{skill_name}"
```
**作用**: 生成组合键,格式为 `{category}:{name}`
**影响**:
- 新增方法,不影响现有代码
-`is_skill_enabled()` 和 API 路由使用
---
#### 1.2 修改方法: `is_skill_enabled()`
**位置**: 第 168-195 行
**修改前**:
```python
def is_skill_enabled(self, skill_name: str, skill_category: str) -> bool:
skill_config = self.skills.get(skill_name)
if skill_config is None:
return skill_category in ("public", "custom")
return skill_config.enabled
```
**修改后**:
```python
def is_skill_enabled(self, skill_name: str, skill_category: str) -> bool:
"""Check if a skill is enabled.
First checks for the new format key '{category}:{name}', then falls back
to the old format '{name}' for backward compatibility.
Args:
skill_name: Name of the skill
skill_category: Category of the skill
Returns:
True if enabled, False otherwise
"""
# Try new format first: {category}:{name}
skill_key = self.get_skill_key(skill_name, skill_category)
skill_config = self.skills.get(skill_key)
if skill_config is not None:
return skill_config.enabled
# Fallback to old format for backward compatibility: {name}
# Only check old format if category is 'public' to avoid conflicts
if skill_category == "public":
skill_config = self.skills.get(skill_name)
if skill_config is not None:
return skill_config.enabled
# Default to enabled for public & custom skills
return skill_category in ("public", "custom")
```
**改动说明**:
- 优先检查新格式键 `{category}:{name}`
- 向后兼容:如果新格式不存在,检查旧格式(仅 public 类别)
- 保持默认行为:未配置时默认启用
**影响**:
- ✅ 向后兼容:旧配置仍可正常工作
- ✅ 新配置使用组合键,避免冲突
- ✅ 不影响现有调用方
---
### 二、后端技能加载器 (`backend/packages/harness/deerflow/skills/loader.py`)
#### 2.1 添加重复检查逻辑
**位置**: 第 54-86 行
**修改前**:
```python
skills = []
# Scan public and custom directories
for category in ["public", "custom"]:
category_path = skills_path / category
# ... 扫描技能目录 ...
skill = parse_skill_file(skill_file, category=category)
if skill:
skills.append(skill)
```
**修改后**:
```python
skills = []
category_skill_names = {} # Track skill names per category to detect duplicates
# Scan public and custom directories
for category in ["public", "custom"]:
category_path = skills_path / category
if not category_path.exists() or not category_path.is_dir():
continue
# Initialize tracking for this category
if category not in category_skill_names:
category_skill_names[category] = {}
# Each subdirectory is a potential skill
for skill_dir in category_path.iterdir():
# ... 扫描逻辑 ...
skill = parse_skill_file(skill_file, category=category)
if skill:
# Validate: each category cannot have duplicate skill names
if skill.name in category_skill_names[category]:
existing_path = category_skill_names[category][skill.name]
raise ValueError(
f"Duplicate skill name '{skill.name}' found in {category} category. "
f"Existing: {existing_path}, Duplicate: {skill_file.parent}"
)
category_skill_names[category][skill.name] = str(skill_file.parent)
skills.append(skill)
```
**改动说明**:
- 为每个类别维护技能名称字典
- 检测到重复时抛出 `ValueError`,包含详细路径信息
- 确保每个类别内技能名称唯一
**影响**:
- ✅ 防止配置冲突
- ✅ 清晰的错误提示
- ⚠️ 如果存在重复,加载会失败(这是预期行为)
---
### 三、后端 API 路由 (`backend/app/gateway/routers/skills.py`)
#### 3.1 新增辅助函数: `_find_skill_by_name()`
**位置**: 第 136-173 行
**代码**:
```python
def _find_skill_by_name(
skills: list[Skill], skill_name: str, category: str | None = None
) -> Skill:
"""Find a skill by name, optionally filtered by category.
Args:
skills: List of all skills
skill_name: Name of the skill to find
category: Optional category filter
Returns:
The found Skill object
Raises:
HTTPException: If skill not found or multiple skills require category
"""
if category:
skill = next((s for s in skills if s.name == skill_name and s.category == category), None)
if skill is None:
raise HTTPException(
status_code=404,
detail=f"Skill '{skill_name}' with category '{category}' not found"
)
return skill
# If no category provided, check if there are multiple skills with the same name
matching_skills = [s for s in skills if s.name == skill_name]
if len(matching_skills) == 0:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
elif len(matching_skills) > 1:
# Multiple skills with same name - require category
categories = [s.category for s in matching_skills]
raise HTTPException(
status_code=400,
detail=f"Multiple skills found with name '{skill_name}'. Please specify category query parameter. "
f"Available categories: {', '.join(categories)}"
)
return matching_skills[0]
```
**作用**:
- 统一技能查找逻辑
- 支持可选的 category 过滤
- 自动检测同名冲突并提示
**影响**:
- ✅ 减少代码重复(约 30 行)
- ✅ 统一错误处理逻辑
---
#### 3.2 修改端点: `GET /api/skills/{skill_name}`
**位置**: 第 196-260 行
**修改前**:
```python
@router.get("/skills/{skill_name}", ...)
async def get_skill(skill_name: str) -> SkillResponse:
skills = load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
return _skill_to_response(skill)
```
**修改后**:
```python
@router.get(
"/skills/{skill_name}",
response_model=SkillResponse,
summary="Get Skill Details",
description="Retrieve detailed information about a specific skill by its name. "
"If multiple skills share the same name, use category query parameter.",
)
async def get_skill(skill_name: str, category: str | None = None) -> SkillResponse:
try:
skills = load_skills(enabled_only=False)
skill = _find_skill_by_name(skills, skill_name, category)
return _skill_to_response(skill)
except ValueError as e:
# ValueError indicates duplicate skill names in a category
logger.error(f"Invalid skills configuration: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
```
**改动说明**:
- 添加可选的 `category` 查询参数
- 使用 `_find_skill_by_name()` 统一查找逻辑
- 添加 `ValueError` 处理(重复检查错误)
**API 变更**:
- ✅ 向后兼容:`category` 参数可选
- ✅ 如果只有一个同名技能,自动匹配
- ✅ 如果有多个同名技能,要求提供 `category`
---
#### 3.3 修改端点: `PUT /api/skills/{skill_name}`
**位置**: 第 267-388 行
**修改前**:
```python
@router.put("/skills/{skill_name}", ...)
async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillResponse:
skills = load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled)
# ... 保存配置 ...
```
**修改后**:
```python
@router.put(
"/skills/{skill_name}",
response_model=SkillResponse,
summary="Update Skill",
description="Update a skill's enabled status by modifying the extensions_config.json file. "
"Requires category query parameter to uniquely identify skills with the same name.",
)
async def update_skill(skill_name: str, request: SkillUpdateRequest, category: str | None = None) -> SkillResponse:
try:
# Find the skill to verify it exists
skills = load_skills(enabled_only=False)
skill = _find_skill_by_name(skills, skill_name, category)
# Get or create config path
config_path = ExtensionsConfig.resolve_config_path()
# ... 配置路径处理 ...
# Load current configuration
extensions_config = get_extensions_config()
# Use the new format key: {category}:{name}
skill_key = ExtensionsConfig.get_skill_key(skill.name, skill.category)
extensions_config.skills[skill_key] = SkillStateConfig(enabled=request.enabled)
# Convert to JSON format (preserve MCP servers config)
config_data = {
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
"skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()},
}
# Write the configuration to file
with open(config_path, "w") as f:
json.dump(config_data, f, indent=2)
# Reload the extensions config to update the global cache
reload_extensions_config()
# Reload the skills to get the updated status (for API response)
skills = load_skills(enabled_only=False)
updated_skill = next((s for s in skills if s.name == skill.name and s.category == skill.category), None)
if updated_skill is None:
raise HTTPException(
status_code=500,
detail=f"Failed to reload skill '{skill.name}' (category: {skill.category}) after update"
)
logger.info(f"Skill '{skill.name}' (category: {skill.category}) enabled status updated to {request.enabled}")
return _skill_to_response(updated_skill)
except ValueError as e:
# ValueError indicates duplicate skill names in a category
logger.error(f"Invalid skills configuration: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
```
**改动说明**:
- 添加可选的 `category` 查询参数
- 使用 `_find_skill_by_name()` 查找技能
- **关键改动**: 使用组合键 `ExtensionsConfig.get_skill_key()` 存储配置
- 添加 `ValueError` 处理
**API 变更**:
- ✅ 向后兼容:`category` 参数可选
- ✅ 配置存储使用新格式键
---
#### 3.4 修改端点: `POST /api/skills/install`
**位置**: 第 392-529 行
**修改前**:
```python
# Check if skill already exists
target_dir = custom_skills_dir / skill_name
if target_dir.exists():
raise HTTPException(status_code=409, detail=f"Skill '{skill_name}' already exists. Please remove it first or use a different name.")
```
**修改后**:
```python
# Check if skill directory already exists
target_dir = custom_skills_dir / skill_name
if target_dir.exists():
raise HTTPException(status_code=409, detail=f"Skill directory '{skill_name}' already exists. Please remove it first or use a different name.")
# Check if a skill with the same name already exists in custom category
# This prevents duplicate skill names even if directory names differ
try:
existing_skills = load_skills(enabled_only=False)
duplicate_skill = next(
(s for s in existing_skills if s.name == skill_name and s.category == "custom"),
None
)
if duplicate_skill:
raise HTTPException(
status_code=409,
detail=f"Skill with name '{skill_name}' already exists in custom category "
f"(located at: {duplicate_skill.skill_dir}). Please remove it first or use a different name."
)
except ValueError as e:
# ValueError indicates duplicate skill names in configuration
# This should not happen during installation, but handle it gracefully
logger.warning(f"Skills configuration issue detected during installation: {e}")
raise HTTPException(
status_code=500,
detail=f"Cannot install skill: {str(e)}"
)
```
**改动说明**:
- 检查目录是否存在(原有逻辑)
- **新增**: 检查 custom 类别中是否已有同名技能(即使目录名不同)
- 添加 `ValueError` 处理
**影响**:
- ✅ 防止安装同名技能
- ✅ 清晰的错误提示
---
### 四、前端 API 层 (`frontend/src/core/skills/api.ts`)
#### 4.1 修改函数: `enableSkill()`
**位置**: 第 11-30 行
**修改前**:
```typescript
export async function enableSkill(skillName: string, enabled: boolean) {
const response = await fetch(
`${getBackendBaseURL()}/api/skills/${skillName}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
enabled,
}),
},
);
return response.json();
}
```
**修改后**:
```typescript
export async function enableSkill(
skillName: string,
enabled: boolean,
category: string,
) {
const baseURL = getBackendBaseURL();
const skillNameEncoded = encodeURIComponent(skillName);
const categoryEncoded = encodeURIComponent(category);
const url = `${baseURL}/api/skills/${skillNameEncoded}?category=${categoryEncoded}`;
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
enabled,
}),
});
return response.json();
}
```
**改动说明**:
- 添加 `category` 参数
- URL 编码 skillName 和 category
- 将 category 作为查询参数传递
**影响**:
- ✅ 必须传递 category前端已有该信息
- ✅ URL 编码确保特殊字符正确处理
---
### 五、前端 Hooks 层 (`frontend/src/core/skills/hooks.ts`)
#### 5.1 修改 Hook: `useEnableSkill()`
**位置**: 第 15-33 行
**修改前**:
```typescript
export function useEnableSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
skillName,
enabled,
}: {
skillName: string;
enabled: boolean;
}) => {
await enableSkill(skillName, enabled);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
```
**修改后**:
```typescript
export function useEnableSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
skillName,
enabled,
category,
}: {
skillName: string;
enabled: boolean;
category: string;
}) => {
await enableSkill(skillName, enabled, category);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
```
**改动说明**:
- 添加 `category` 参数到类型定义
- 传递 `category``enableSkill()` API 调用
**影响**:
- ✅ 类型安全
- ✅ 必须传递 category
---
### 六、前端组件层 (`frontend/src/components/workspace/settings/skill-settings-page.tsx`)
#### 6.1 修改组件: `SkillSettingsList`
**位置**: 第 92-119 行
**修改前**:
```typescript
{filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<Item className="w-full" variant="outline" key={skill.name}>
{/* ... */}
<Switch
checked={skill.enabled}
onCheckedChange={(checked) =>
enableSkill({ skillName: skill.name, enabled: checked })
}
/>
</Item>
))}
```
**修改后**:
```typescript
{filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<Item
className="w-full"
variant="outline"
key={`${skill.category}:${skill.name}`}
>
{/* ... */}
<Switch
checked={skill.enabled}
onCheckedChange={(checked) =>
enableSkill({
skillName: skill.name,
enabled: checked,
category: skill.category,
})
}
/>
</Item>
))}
```
**改动说明**:
- **关键改动**: React key 从 `skill.name` 改为 `${skill.category}:${skill.name}`
- 传递 `category``enableSkill()`
**影响**:
- ✅ 确保 React key 唯一性(避免同名技能冲突)
- ✅ 正确传递 category 信息
---
## 配置格式变更
### 旧格式(向后兼容)
```json
{
"skills": {
"my-skill": {
"enabled": true
}
}
}
```
### 新格式(推荐)
```json
{
"skills": {
"public:my-skill": {
"enabled": true
},
"custom:my-skill": {
"enabled": false
}
}
}
```
### 迁移说明
-**自动兼容**: 系统会自动识别旧格式
-**无需手动迁移**: 旧配置继续工作
-**新配置使用新格式**: 更新技能状态时自动使用新格式键
---
## API 变更
### GET /api/skills/{skill_name}
**新增查询参数**:
- `category` (可选): `public``custom`
**行为变更**:
- 如果只有一个同名技能,自动匹配(向后兼容)
- 如果有多个同名技能,必须提供 `category` 参数
**示例**:
```bash
# 单个技能(向后兼容)
GET /api/skills/my-skill
# 多个同名技能(必须指定类别)
GET /api/skills/my-skill?category=public
GET /api/skills/my-skill?category=custom
```
### PUT /api/skills/{skill_name}
**新增查询参数**:
- `category` (可选): `public``custom`
**行为变更**:
- 配置存储使用新格式键 `{category}:{name}`
- 如果只有一个同名技能,自动匹配(向后兼容)
- 如果有多个同名技能,必须提供 `category` 参数
**示例**:
```bash
# 更新 public 技能
PUT /api/skills/my-skill?category=public
Body: { "enabled": true }
# 更新 custom 技能
PUT /api/skills/my-skill?category=custom
Body: { "enabled": false }
```
---
## 影响范围
### 后端
1. **配置读取**: `ExtensionsConfig.is_skill_enabled()` - 支持新格式,向后兼容
2. **配置写入**: `PUT /api/skills/{skill_name}` - 使用新格式键
3. **技能加载**: `load_skills()` - 添加重复检查
4. **API 端点**: 3 个端点支持可选的 `category` 参数
### 前端
1. **API 调用**: `enableSkill()` - 必须传递 `category`
2. **Hooks**: `useEnableSkill()` - 类型定义更新
3. **组件**: `SkillSettingsList` - React key 和参数传递更新
### 配置文件
- **格式变更**: 新配置使用 `{category}:{name}` 格式
- **向后兼容**: 旧格式继续支持
- **自动迁移**: 更新时自动使用新格式
---
## 测试建议
### 1. 向后兼容性测试
- [ ] 旧格式配置文件应正常工作
- [ ] 仅使用 `skill_name` 的 API 调用应正常工作(单个技能时)
- [ ] 现有技能状态应保持不变
### 2. 新功能测试
- [ ] public 和 custom 同名技能应能独立控制
- [ ] 打开/关闭一个技能不应影响另一个同名技能
- [ ] API 调用传递 `category` 参数应正确工作
### 3. 错误处理测试
- [ ] public 类别内重复技能名称应报错
- [ ] custom 类别内重复技能名称应报错
- [ ] 多个同名技能时,不提供 `category` 应返回 400 错误
### 4. 安装测试
- [ ] 安装同名技能应被拒绝409 错误)
- [ ] 错误信息应包含现有技能的位置
---
## 已知问题(暂时保留)
### ⚠️ 问题描述
**当前状态**: 同名技能冲突问题已识别但**暂时保留**,后续版本修复
**问题表现**:
- 如果 public 和 custom 目录下存在同名技能,虽然配置已使用组合键区分,但前端 UI 可能仍会出现混淆
- 用户可能无法清楚区分哪个是 public哪个是 custom
**影响范围**:
- 用户体验:可能无法清楚区分同名技能
- 功能:技能状态可以独立控制(已修复)
- 数据:配置正确存储(已修复)
### 后续修复建议
1. **UI 增强**: 在技能列表中明确显示类别标识
2. **名称验证**: 安装时检查是否与 public 技能同名,并给出警告
3. **文档更新**: 说明同名技能的最佳实践
---
## 回滚方案
如果需要回滚这些改动:
### 后端回滚
1. **恢复配置读取逻辑**:
```python
# 恢复为仅使用 skill_name
skill_config = self.skills.get(skill_name)
```
2. **恢复 API 端点**:
- 移除 `category` 参数
- 恢复原有的查找逻辑
3. **移除重复检查**:
- 移除 `category_skill_names` 跟踪逻辑
### 前端回滚
1. **恢复 API 调用**:
```typescript
// 移除 category 参数
export async function enableSkill(skillName: string, enabled: boolean)
```
2. **恢复组件**:
- React key 恢复为 `skill.name`
- 移除 `category` 参数传递
### 配置迁移
- 新格式配置需要手动迁移回旧格式(如果已使用新格式)
- 旧格式配置无需修改
---
## 总结
### 改动统计
- **后端文件**: 3 个文件修改
- `backend/packages/harness/deerflow/config/extensions_config.py`: +1 方法,修改 1 方法
- `backend/packages/harness/deerflow/skills/loader.py`: +重复检查逻辑
- `backend/app/gateway/routers/skills.py`: +1 辅助函数,修改 3 个端点
- **前端文件**: 3 个文件修改
- `frontend/src/core/skills/api.ts`: 修改 1 个函数
- `frontend/src/core/skills/hooks.ts`: 修改 1 个 hook
- `frontend/src/components/workspace/settings/skill-settings-page.tsx`: 修改组件
- **代码行数**:
- 新增: ~80 行
- 修改: ~30 行
- 删除: ~0 行(向后兼容)
### 核心改进
1.**配置唯一性**: 使用组合键确保配置唯一
2.**向后兼容**: 旧配置继续工作
3.**重复检查**: 防止配置冲突
4.**代码复用**: 提取公共函数减少重复
5.**错误提示**: 清晰的错误信息
### 注意事项
- ⚠️ **已知问题保留**: UI 区分同名技能的问题待后续修复
-**向后兼容**: 现有配置和 API 调用继续工作
-**最小改动**: 仅修改必要的代码
---
**文档版本**: 1.0
**最后更新**: 2026-02-10
**维护者**: AI Assistant

View File

@@ -0,0 +1,105 @@
# Langfuse Tracing Implementation Plan
**Goal:** Add optional Langfuse observability support to DeerFlow while preserving existing LangSmith tracing and allowing both providers to be enabled at the same time.
**Architecture:** Extend tracing configuration from a single LangSmith-only shape to a multi-provider config, add a tracing callback factory that builds zero, one, or two callbacks based on environment variables, and update model creation to attach those callbacks. If a provider is explicitly enabled but misconfigured or fails to initialize, tracing initialization during model creation should fail with a clear error naming that provider.
**Tech Stack:** Python 3.12, Pydantic, LangChain callbacks, LangSmith, Langfuse, pytest
---
### Task 1: Add failing tracing config tests
**Files:**
- Modify: `backend/tests/test_tracing_config.py`
**Step 1: Write the failing tests**
Add tests covering:
- Langfuse-only config parsing
- dual-provider parsing
- explicit enable with missing required Langfuse fields
- provider enable detection without relying on LangSmith-only helpers
**Step 2: Run tests to verify they fail**
Run: `cd backend && uv run pytest tests/test_tracing_config.py -q`
Expected: FAIL because tracing config only supports LangSmith today.
**Step 3: Write minimal implementation**
Update tracing config code to represent multiple providers and expose helper functions needed by the tests.
**Step 4: Run tests to verify they pass**
Run: `cd backend && uv run pytest tests/test_tracing_config.py -q`
Expected: PASS
### Task 2: Add failing callback factory and model attachment tests
**Files:**
- Modify: `backend/tests/test_model_factory.py`
- Create: `backend/tests/test_tracing_factory.py`
**Step 1: Write the failing tests**
Add tests covering:
- LangSmith callback creation
- Langfuse callback creation
- dual callback creation
- startup failure when an explicitly enabled provider cannot initialize
- model factory appends all tracing callbacks to model callbacks
**Step 2: Run tests to verify they fail**
Run: `cd backend && uv run pytest tests/test_model_factory.py tests/test_tracing_factory.py -q`
Expected: FAIL because there is no provider factory and model creation only attaches LangSmith.
**Step 3: Write minimal implementation**
Create tracing callback factory module and update model factory to use it.
**Step 4: Run tests to verify they pass**
Run: `cd backend && uv run pytest tests/test_model_factory.py tests/test_tracing_factory.py -q`
Expected: PASS
### Task 3: Wire dependency and docs
**Files:**
- Modify: `backend/packages/harness/pyproject.toml`
- Modify: `README.md`
- Modify: `backend/README.md`
**Step 1: Update dependency**
Add `langfuse` to the harness dependencies.
**Step 2: Update docs**
Document:
- Langfuse environment variables
- dual-provider behavior
- failure behavior for explicitly enabled providers
**Step 3: Run targeted verification**
Run: `cd backend && uv run pytest tests/test_tracing_config.py tests/test_model_factory.py tests/test_tracing_factory.py -q`
Expected: PASS
### Task 4: Run broader regression checks
**Files:**
- No code changes required
**Step 1: Run relevant suite**
Run: `cd backend && uv run pytest tests/test_tracing_config.py tests/test_model_factory.py tests/test_tracing_factory.py -q`
**Step 2: Run lint if needed**
Run: `cd backend && uv run ruff check packages/harness/deerflow/config/tracing_config.py packages/harness/deerflow/models/factory.py packages/harness/deerflow/tracing`
**Step 3: Review diff**
Run: `git diff -- backend/packages/harness backend/tests README.md backend/README.md`

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB