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:
939
deer-flow/docs/CODE_CHANGE_SUMMARY_BY_FILE.md
Normal file
939
deer-flow/docs/CODE_CHANGE_SUMMARY_BY_FILE.md
Normal 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>` 整段(原约 243–266 行)、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`。
|
||||
- **第 6–7 行**:`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` 两个函数(约 25–62 行)。
|
||||
|
||||
```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、useThread;parsed/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 `1,000`)
|
||||
-- **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 `` 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 |
|
||||
| 新增文件 | 1(markdown-content.tsx) |
|
||||
| 删除文件 | 5(safe-citation-content.tsx, inline-citation.tsx, core/citations/* 共 3 个) |
|
||||
| 总行数变化 | +62 / -894(diff stat) |
|
||||
|
||||
以上为按文件、细到每一行 diff 的代码更改总结。
|
||||
865
deer-flow/docs/SKILL_NAME_CONFLICT_FIX.md
Normal file
865
deer-flow/docs/SKILL_NAME_CONFLICT_FIX.md
Normal 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
|
||||
105
deer-flow/docs/plans/2026-04-01-langfuse-tracing.md
Normal file
105
deer-flow/docs/plans/2026-04-01-langfuse-tracing.md
Normal 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 |
BIN
deer-flow/docs/pr-evidence/skill-manage-e2e-20260406-194030.png
Normal file
BIN
deer-flow/docs/pr-evidence/skill-manage-e2e-20260406-194030.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
Reference in New Issue
Block a user