From 554ec7a91e56e454058baf8bb67fb8951fbb4e82 Mon Sep 17 00:00:00 2001 From: Henry Li Date: Mon, 9 Feb 2026 19:02:21 +0800 Subject: [PATCH 1/9] feat: basic implmenetation --- .../workspace/messages/markdown-content.tsx | 13 +- .../workspace/messages/message-list-item.tsx | 127 ++++++++++++++---- .../workspace/messages/message-list.tsx | 4 +- 3 files changed, 110 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx index 12465de..d6e1ebe 100644 --- a/frontend/src/components/workspace/messages/markdown-content.tsx +++ b/frontend/src/components/workspace/messages/markdown-content.tsx @@ -1,7 +1,6 @@ "use client"; -import type { ImgHTMLAttributes } from "react"; -import type { ReactNode } from "react"; +import { useMemo } from "react"; import { MessageResponse, @@ -16,7 +15,7 @@ export type MarkdownContentProps = { className?: string; remarkPlugins?: MessageResponseProps["remarkPlugins"]; isHuman?: boolean; - img?: (props: ImgHTMLAttributes & { threadId?: string; maxWidth?: string }) => ReactNode; + components?: MessageResponseProps["components"]; }; /** Renders markdown content. */ @@ -25,10 +24,14 @@ export function MarkdownContent({ rehypePlugins, className, remarkPlugins = streamdownPlugins.remarkPlugins, - img, + components: componentsFromProps, }: MarkdownContentProps) { + const components = useMemo(() => { + return { + ...componentsFromProps, + }; + }, [componentsFromProps]); if (!content) return null; - const components = img ? { img } : undefined; return ( @@ -81,13 +87,13 @@ function MessageImage({ if (!src) return null; const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`); - + if (typeof src !== "string") { return {alt}; } - + const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src; - + return ( {alt} @@ -107,12 +113,42 @@ function MessageContent_({ const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const isHuman = message.type === "human"; const { thread_id } = useParams<{ thread_id: string }>(); + const components = useMemo(() => { + return { + a: (props: HTMLAttributes) => { + if (typeof props.children === "string") { + // const match = /^\$(\d+):(.+)$/.exec(props.children); + const match = /^citation:(.+)$/.exec(props.children); + if (match) { + const [, text] = match; + return ( + + Citation - {text} + + ); + } + } + return ; + }, + img: (props: ImgHTMLAttributes) => { + return ; + }, + }; + }, [thread_id]); const rawContent = extractContentFromMessage(message); const reasoningContent = extractReasoningContentFromMessage(message); const { contentToParse, uploadedFiles } = useMemo(() => { if (!isLoading && reasoningContent && !rawContent) { - return { contentToParse: reasoningContent, uploadedFiles: [] as UploadedFile[] }; + return { + contentToParse: reasoningContent, + uploadedFiles: [] as UploadedFile[], + }; } if (isHuman && rawContent) { const { files, cleanContent: contentWithoutFiles } = @@ -125,15 +161,17 @@ function MessageContent_({ }; }, [isLoading, rawContent, reasoningContent, isHuman]); - const filesList = uploadedFiles.length > 0 && thread_id ? ( - - ) : null; + const filesList = + uploadedFiles.length > 0 && thread_id ? ( + + ) : null; if (isHuman) { const messageResponse = contentToParse ? ( {contentToParse} @@ -159,13 +197,7 @@ function MessageContent_({ rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]} className="my-3" isHuman={false} - img={(props) => ( - - )} + components={components} /> ); @@ -174,14 +206,33 @@ function MessageContent_({ /** * Get file extension and check helpers */ -const getFileExt = (filename: string) => filename.split(".").pop()?.toLowerCase() ?? ""; +const getFileExt = (filename: string) => + filename.split(".").pop()?.toLowerCase() ?? ""; const FILE_TYPE_MAP: Record = { - json: "JSON", csv: "CSV", txt: "TXT", md: "Markdown", - py: "Python", js: "JavaScript", ts: "TypeScript", tsx: "TSX", jsx: "JSX", - html: "HTML", css: "CSS", xml: "XML", yaml: "YAML", yml: "YAML", - pdf: "PDF", png: "PNG", jpg: "JPG", jpeg: "JPEG", gif: "GIF", - svg: "SVG", zip: "ZIP", tar: "TAR", gz: "GZ", + json: "JSON", + csv: "CSV", + txt: "TXT", + md: "Markdown", + py: "Python", + js: "JavaScript", + ts: "TypeScript", + tsx: "TSX", + jsx: "JSX", + html: "HTML", + css: "CSS", + xml: "XML", + yaml: "YAML", + yml: "YAML", + pdf: "PDF", + png: "PNG", + jpg: "JPG", + jpeg: "JPEG", + gif: "GIF", + svg: "SVG", + zip: "ZIP", + tar: "TAR", + gz: "GZ", }; const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]; @@ -198,13 +249,23 @@ function isImageFile(filename: string): boolean { /** * Uploaded files list component */ -function UploadedFilesList({ files, threadId }: { files: UploadedFile[]; threadId: string }) { +function UploadedFilesList({ + files, + threadId, +}: { + files: UploadedFile[]; + threadId: string; +}) { if (files.length === 0) return null; return (
{files.map((file, index) => ( - + ))}
); @@ -213,7 +274,13 @@ function UploadedFilesList({ files, threadId }: { files: UploadedFile[]; threadI /** * Single uploaded file card component */ -function UploadedFileCard({ file, threadId }: { file: UploadedFile; threadId: string }) { +function UploadedFileCard({ + file, + threadId, +}: { + file: UploadedFile; + threadId: string; +}) { if (!threadId) return null; const isImage = isImageFile(file.filename); @@ -240,12 +307,18 @@ function UploadedFileCard({ file, threadId }: { file: UploadedFile; threadId: st
- + {file.filename}
- + {getFileTypeLabel(file.filename)} {file.size} diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 9c0b1b0..8f577fd 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -1,3 +1,4 @@ +import type { Message } from "@langchain/langgraph-sdk"; import type { UseStream } from "@langchain/langgraph-sdk/react"; import { @@ -18,15 +19,14 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; -import type { Message } from "@langchain/langgraph-sdk"; import { cn } from "@/lib/utils"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { StreamingIndicator } from "../streaming-indicator"; +import { MarkdownContent } from "./markdown-content"; import { MessageGroup } from "./message-group"; import { MessageListItem } from "./message-list-item"; -import { MarkdownContent } from "./markdown-content"; import { MessageListSkeleton } from "./skeleton"; import { SubtaskCard } from "./subtask-card"; From b6da3a219e30c64682058e3440e4322644e3ff77 Mon Sep 17 00:00:00 2001 From: JeffJiang Date: Mon, 9 Feb 2026 21:59:13 +0800 Subject: [PATCH 2/9] Add Kubernetes-based sandbox provider for multi-instance support (#19) * feat: adds docker-based dev environment * docs: updates Docker command help * fix local dev * feat(sandbox): add Kubernetes-based sandbox provider for multi-instance support * fix: skills path in k8s * feat: add example config for k8s sandbox * fix: docker config * fix: load skills on docker dev * feat: support sandbox execution to Kubernetes Deployment model * chore: rename web service name --- .dockerignore | 17 + CONTRIBUTING.md | 14 +- Makefile | 14 +- README.md | 32 +- backend/Dockerfile | 6 + backend/langgraph.json | 6 +- backend/pyproject.toml | 6 +- backend/src/agents/lead_agent/agent.py | 4 + backend/src/community/aio_sandbox/__init__.py | 5 +- backend/uv.lock | 64 ++- config.example.yaml | 20 +- docker/docker-compose-dev.yaml | 64 ++- docker/k8s/README.md | 427 ++++++++++++++++++ docker/k8s/namespace.yaml | 7 + docker/k8s/sandbox-deployment.yaml | 65 +++ docker/k8s/sandbox-service.yaml | 21 + docker/k8s/setup.sh | 245 ++++++++++ docker/nginx/nginx.conf | 4 +- frontend/Dockerfile | 6 + scripts/docker.sh | 48 +- 20 files changed, 981 insertions(+), 94 deletions(-) create mode 100644 docker/k8s/README.md create mode 100644 docker/k8s/namespace.yaml create mode 100644 docker/k8s/sandbox-deployment.yaml create mode 100644 docker/k8s/sandbox-service.yaml create mode 100755 docker/k8s/setup.sh diff --git a/.dockerignore b/.dockerignore index 3faf7a1..3f151c2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -51,3 +51,20 @@ examples/ assets/ tests/ *.log + +# Exclude directories not needed in Docker context +# Frontend build only needs frontend/ +# Backend build only needs backend/ +scripts/ +logs/ +docker/ +skills/ +frontend/.next +frontend/node_modules +backend/.venv +backend/htmlcov +backend/.coverage +*.md +!README.md +!frontend/README.md +!backend/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1d048e..324afd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ Docker provides a consistent, isolated environment with all dependencies pre-con 3. **Start development services**: ```bash - make docker-dev + make docker-start ``` All services will start with hot-reload enabled: - Frontend changes are automatically reloaded @@ -59,20 +59,16 @@ Docker provides a consistent, isolated environment with all dependencies pre-con ```bash # View all logs -make docker-dev-logs - -# View specific service logs -./scripts/docker.sh logs --web # Frontend only -./scripts/docker.sh logs --api # Backend only +make docker-logs # Restart services -./scripts/docker.sh restart +make docker-restart # Stop services -make docker-dev-stop +make docker-stop # Get help -./scripts/docker.sh help +make docker-help ``` #### Docker Architecture diff --git a/Makefile b/Makefile index 6a7a664..8d96acc 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # DeerFlow - Unified Development Environment -.PHONY: help check install dev stop clean docker-init docker-start docker-stop docker-logs docker-logs-web docker-logs-api +.PHONY: help check install dev stop clean docker-init docker-start docker-stop docker-logs docker-logs-frontend docker-logs-gateway help: @echo "DeerFlow Development Commands:" @@ -16,8 +16,8 @@ help: @echo " make docker-start - Start all services in Docker (localhost:2026)" @echo " make docker-stop - Stop Docker development services" @echo " make docker-logs - View Docker development logs" - @echo " make docker-logs-web - View Docker frontend logs" - @echo " make docker-logs-api - View Docker backend logs" + @echo " make docker-logs-frontend - View Docker frontend logs" + @echo " make docker-logs-gateway - View Docker gateway logs" # Check required tools check: @@ -251,7 +251,7 @@ docker-logs: @./scripts/docker.sh logs # View Docker development logs -docker-logs-web: - @./scripts/docker.sh logs --web -docker-logs-api: - @./scripts/docker.sh logs --api \ No newline at end of file +docker-logs-frontend: + @./scripts/docker.sh logs --frontend +docker-logs-gateway: + @./scripts/docker.sh logs --gateway \ No newline at end of file diff --git a/README.md b/README.md index 4ee83db..b1f03f4 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,7 @@ The fastest way to get started with a consistent environment: 2. **Initialize and start**: ```bash - make docker-init # First time only - make docker-dev # Start all services + make docker-start # Start all services ``` 3. **Access**: http://localhost:2026 @@ -56,6 +55,35 @@ If you prefer running services locally: See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed local development guide. +### Sandbox Configuration + +DeerFlow supports multiple sandbox execution modes. Configure your preferred mode in `config.yaml`: + +**Local Execution** (runs sandbox code directly on the host machine): +```yaml +sandbox: + use: src.sandbox.local:LocalSandboxProvider # Local execution +``` + +**Docker Execution** (runs sandbox code in isolated Docker containers): +```yaml +sandbox: + use: src.community.aio_sandbox:AioSandboxProvider # Docker-based sandbox +``` + +**Docker Execution with Kubernetes** (runs sandbox code in Kubernetes pods): + +Setup Kubernetes sandbox as per [Kubernetes Sandbox Setup](docker/k8s/README.md). +```bash +./docker/k8s/setup.sh +``` +Then configure `config.yaml` with the Kubernetes service URL: +```yaml +sandbox: + use: src.community.k8s_sandbox:AioSandboxProvider # Kubernetes-based sandbox + base_url: http://deer-flow-sandbox.deer-flow.svc.cluster.local:8080 # Kubernetes service URL +``` + ## Features - 🤖 **LangGraph-based Agents** - Multi-agent orchestration with sophisticated workflows diff --git a/backend/Dockerfile b/backend/Dockerfile index e470aea..8058cc0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,6 +14,12 @@ ENV PATH="/root/.local/bin:$PATH" # Set working directory WORKDIR /app +# Copy frontend source code +COPY backend ./backend + +# Install dependencies +RUN sh -c "cd backend && uv sync" + # Expose ports (gateway: 8001, langgraph: 2024) EXPOSE 8001 2024 diff --git a/backend/langgraph.json b/backend/langgraph.json index 7c2c842..c89eeef 100644 --- a/backend/langgraph.json +++ b/backend/langgraph.json @@ -1,8 +1,10 @@ { "$schema": "https://langgra.ph/schema.json", - "dependencies": ["."], + "dependencies": [ + "." + ], "env": ".env", "graphs": { "lead_agent": "src.agents:make_lead_agent" } -} +} \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 680d595..49f089c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "dotenv>=0.9.9", "fastapi>=0.115.0", "httpx>=0.28.0", + "kubernetes>=30.0.0", "langchain>=1.2.3", "langchain-deepseek>=1.0.1", "langchain-mcp-adapters>=0.1.0", @@ -30,7 +31,4 @@ dependencies = [ ] [dependency-groups] -dev = [ - "pytest>=8.0.0", - "ruff>=0.14.11", -] +dev = ["pytest>=8.0.0", "ruff>=0.14.11"] diff --git a/backend/src/agents/lead_agent/agent.py b/backend/src/agents/lead_agent/agent.py index 9aecbf6..0c44116 100644 --- a/backend/src/agents/lead_agent/agent.py +++ b/backend/src/agents/lead_agent/agent.py @@ -236,8 +236,12 @@ def _build_middlewares(config: RunnableConfig): def make_lead_agent(config: RunnableConfig): # Lazy import to avoid circular dependency + import logging + from src.tools import get_available_tools + logging.basicConfig(level=logging.INFO) + thinking_enabled = config.get("configurable", {}).get("thinking_enabled", True) model_name = config.get("configurable", {}).get("model_name") or config.get("configurable", {}).get("model") is_plan_mode = config.get("configurable", {}).get("is_plan_mode", False) diff --git a/backend/src/community/aio_sandbox/__init__.py b/backend/src/community/aio_sandbox/__init__.py index 41d3a1c..de320e2 100644 --- a/backend/src/community/aio_sandbox/__init__.py +++ b/backend/src/community/aio_sandbox/__init__.py @@ -1,7 +1,4 @@ from .aio_sandbox import AioSandbox from .aio_sandbox_provider import AioSandboxProvider -__all__ = [ - "AioSandbox", - "AioSandboxProvider", -] +__all__ = ["AioSandbox", "AioSandboxProvider"] diff --git a/backend/uv.lock b/backend/uv.lock index ac2eec9..b8ef839 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -606,6 +606,7 @@ dependencies = [ { name = "fastapi" }, { name = "firecrawl-py" }, { name = "httpx" }, + { name = "kubernetes" }, { name = "langchain" }, { name = "langchain-deepseek" }, { name = "langchain-mcp-adapters" }, @@ -638,6 +639,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "firecrawl-py", specifier = ">=1.15.0" }, { name = "httpx", specifier = ">=0.28.0" }, + { name = "kubernetes", specifier = ">=30.0.0" }, { name = "langchain", specifier = ">=1.2.3" }, { name = "langchain-deepseek", specifier = ">=1.0.1" }, { name = "langchain-mcp-adapters", specifier = ">=0.1.0" }, @@ -691,6 +693,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, ] +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + [[package]] name = "et-xmlfile" version = "2.0.0" @@ -1274,6 +1285,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "kubernetes" +version = "35.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642, upload-time = "2026-01-16T01:05:27.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602, upload-time = "2026-01-16T01:05:25.991Z" }, +] + [[package]] name = "langchain" version = "1.2.3" @@ -1897,6 +1928,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "olefile" version = "0.47" @@ -2841,6 +2881,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + [[package]] name = "requests-toolbelt" version = "1.0.0" @@ -3391,6 +3444,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "websockets" version = "16.0" diff --git a/config.example.yaml b/config.example.yaml index 862dfe5..9006a8e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -18,10 +18,10 @@ models: display_name: GPT-4 use: langchain_openai:ChatOpenAI model: gpt-4 - api_key: $OPENAI_API_KEY # Use environment variable + api_key: $OPENAI_API_KEY # Use environment variable max_tokens: 4096 temperature: 0.7 - supports_vision: true # Enable vision support for view_image tool + supports_vision: true # Enable vision support for view_image tool # Example: Anthropic Claude model # - name: claude-3-5-sonnet @@ -210,7 +210,7 @@ title: enabled: true max_words: 6 max_chars: 60 - model_name: null # Use default model (first model in models list) + model_name: null # Use default model (first model in models list) # ============================================================================ # Summarization Configuration @@ -289,10 +289,10 @@ summarization: # Stores user context and conversation history for personalized responses memory: enabled: true - storage_path: .deer-flow/memory.json # Path relative to backend directory - debounce_seconds: 30 # Wait time before processing queued updates - model_name: null # Use default model - max_facts: 100 # Maximum number of facts to store - fact_confidence_threshold: 0.7 # Minimum confidence for storing facts - injection_enabled: true # Whether to inject memory into system prompt - max_injection_tokens: 2000 # Maximum tokens for memory injection + storage_path: .deer-flow/memory.json # Path relative to backend directory + debounce_seconds: 30 # Wait time before processing queued updates + model_name: null # Use default model + max_facts: 100 # Maximum number of facts to store + fact_confidence_threshold: 0.7 # Minimum confidence for storing facts + injection_enabled: true # Whether to inject memory into system prompt + max_injection_tokens: 2000 # Maximum tokens for memory injection diff --git a/docker/docker-compose-dev.yaml b/docker/docker-compose-dev.yaml index 9a0783b..2ee1d00 100644 --- a/docker/docker-compose-dev.yaml +++ b/docker/docker-compose-dev.yaml @@ -3,8 +3,8 @@ # # Services: # - nginx: Reverse proxy (port 2026) -# - web: Frontend Next.js dev server (port 3000) -# - api: Backend Gateway API (port 8001) +# - frontend: Frontend Next.js dev server (port 3000) +# - gateway: Backend Gateway API (port 8001) # - langgraph: LangGraph server (port 2024) # # Access: http://localhost:2026 @@ -19,29 +19,34 @@ services: volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro depends_on: - - web - - api + - frontend + - gateway - langgraph networks: - deer-flow-dev restart: unless-stopped # Frontend - Next.js Development Server - web: + frontend: build: - context: ../frontend - dockerfile: Dockerfile + context: ../ + dockerfile: frontend/Dockerfile args: PNPM_STORE_PATH: ${PNPM_STORE_PATH:-/root/.local/share/pnpm/store} - container_name: deer-flow-web - command: pnpm run dev + container_name: deer-flow-frontend + command: sh -c "cd frontend && pnpm run dev > /app/logs/frontend.log 2>&1" volumes: - - ../frontend:/app + - ../frontend/src:/app/frontend/src + - ../frontend/public:/app/frontend/public + - ../frontend/next.config.js:/app/frontend/next.config.js:ro + - ../logs:/app/logs # Mount pnpm store for caching - ${PNPM_STORE_PATH:-~/.local/share/pnpm/store}:/root/.local/share/pnpm/store + working_dir: /app environment: - NODE_ENV=development - WATCHPACK_POLLING=true + - CI=true env_file: - ../frontend/.env networks: @@ -49,17 +54,26 @@ services: restart: unless-stopped # Backend - Gateway API - api: + gateway: build: - context: ../backend - dockerfile: Dockerfile - container_name: deer-flow-api - command: uv run uvicorn src.gateway.app:app --host 0.0.0.0 --port 8001 --reload + context: ../ + dockerfile: backend/Dockerfile + container_name: deer-flow-gateway + command: sh -c "cd backend && uv run uvicorn src.gateway.app:app --host 0.0.0.0 --port 8001 --reload --reload-include='*.yaml .env' > /app/logs/gateway.log 2>&1" volumes: - - ../backend:/app - - ../config.yaml:/app/config.yaml:ro + - ../backend/src:/app/backend/src + - ../backend/.env:/app/backend/.env + - ../config.yaml:/app/config.yaml + - ../skills:/app/skills + - ../logs:/app/logs + working_dir: /app + environment: + - CI=true env_file: - ../backend/.env + extra_hosts: + # For Linux: map host.docker.internal to host gateway + - "host.docker.internal:host-gateway" networks: - deer-flow-dev restart: unless-stopped @@ -67,13 +81,19 @@ services: # Backend - LangGraph Server langgraph: build: - context: ../backend - dockerfile: Dockerfile + context: ../ + dockerfile: backend/Dockerfile container_name: deer-flow-langgraph - command: uv run langgraph dev --no-browser --allow-blocking --no-reload --host 0.0.0.0 --port 2024 + command: sh -c "cd backend && uv run langgraph dev --no-browser --allow-blocking --host 0.0.0.0 --port 2024 > /app/logs/langgraph.log 2>&1" volumes: - - ../backend:/app - - ../config.yaml:/app/config.yaml:ro + - ../backend/src:/app/backend/src + - ../backend/.env:/app/backend/.env + - ../config.yaml:/app/config.yaml + - ../skills:/app/skills + - ../logs:/app/logs + working_dir: /app + environment: + - CI=true env_file: - ../backend/.env networks: diff --git a/docker/k8s/README.md b/docker/k8s/README.md new file mode 100644 index 0000000..53ce9ad --- /dev/null +++ b/docker/k8s/README.md @@ -0,0 +1,427 @@ +# Kubernetes Sandbox Setup + +This guide explains how to deploy and configure the DeerFlow sandbox execution environment on Kubernetes. + +## Overview + +The Kubernetes sandbox deployment allows you to run DeerFlow's code execution sandbox in a Kubernetes cluster, providing: + +- **Isolated Execution**: Sandbox runs in dedicated Kubernetes pods +- **Scalability**: Easy horizontal scaling with replica configuration +- **Cluster Integration**: Seamless integration with existing Kubernetes infrastructure +- **Persistent Skills**: Skills directory mounted from host or PersistentVolume + +## Prerequisites + +Before you begin, ensure you have: + +1. **Kubernetes Cluster**: One of the following: + - Docker Desktop with Kubernetes enabled + - OrbStack with Kubernetes enabled + - Minikube + - Any production Kubernetes cluster + +2. **kubectl**: Kubernetes command-line tool + ```bash + # macOS + brew install kubectl + + # Linux + # See: https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/ + ``` + +3. **Docker**: For pulling the sandbox image (optional, but recommended) + ```bash + # Verify installation + docker version + ``` + +## Quick Start + +### 1. Enable Kubernetes + +**Docker Desktop:** +``` +Settings → Kubernetes → Enable Kubernetes → Apply & Restart +``` + +**OrbStack:** +``` +Settings → Enable Kubernetes +``` + +**Minikube:** +```bash +minikube start +``` + +### 2. Run Setup Script + +The easiest way to get started: + +```bash +cd docker/k8s +./setup.sh +``` + +This will: +- ✅ Check kubectl installation and cluster connectivity +- ✅ Pull the sandbox Docker image (optional, can be skipped) +- ✅ Create the `deer-flow` namespace +- ✅ Deploy the sandbox service and deployment +- ✅ Verify the deployment is running + +### 3. Configure Backend + +Add the following to `backend/config.yaml`: + +```yaml +sandbox: + use: src.community.aio_sandbox:AioSandboxProvider + base_url: http://deer-flow-sandbox.deer-flow.svc.cluster.local:8080 +``` + +### 4. Verify Deployment + +Check that the sandbox pod is running: + +```bash +kubectl get pods -n deer-flow +``` + +You should see: +``` +NAME READY STATUS RESTARTS AGE +deer-flow-sandbox-xxxxxxxxxx-xxxxx 1/1 Running 0 1m +``` + +## Advanced Configuration + +### Custom Skills Path + +By default, the setup script uses `PROJECT_ROOT/skills`. You can specify a custom path: + +**Using command-line argument:** +```bash +./setup.sh --skills-path /custom/path/to/skills +``` + +**Using environment variable:** +```bash +SKILLS_PATH=/custom/path/to/skills ./setup.sh +``` + +### Custom Sandbox Image + +To use a different sandbox image: + +**Using command-line argument:** +```bash +./setup.sh --image your-registry/sandbox:tag +``` + +**Using environment variable:** +```bash +SANDBOX_IMAGE=your-registry/sandbox:tag ./setup.sh +``` + +### Skip Image Pull + +If you already have the image locally or want to pull it manually later: + +```bash +./setup.sh --skip-pull +``` + +### Combined Options + +```bash +./setup.sh --skip-pull --skills-path /custom/skills --image custom/sandbox:latest +``` + +## Manual Deployment + +If you prefer manual deployment or need more control: + +### 1. Create Namespace + +```bash +kubectl apply -f namespace.yaml +``` + +### 2. Create Service + +```bash +kubectl apply -f sandbox-service.yaml +``` + +### 3. Deploy Sandbox + +First, update the skills path in `sandbox-deployment.yaml`: + +```bash +# Replace __SKILLS_PATH__ with your actual path +sed 's|__SKILLS_PATH__|/Users/feng/Projects/deer-flow/skills|g' \ + sandbox-deployment.yaml | kubectl apply -f - +``` + +Or manually edit `sandbox-deployment.yaml` and replace `__SKILLS_PATH__` with your skills directory path. + +### 4. Verify Deployment + +```bash +# Check all resources +kubectl get all -n deer-flow + +# Check pod status +kubectl get pods -n deer-flow + +# Check pod logs +kubectl logs -n deer-flow -l app=deer-flow-sandbox + +# Describe pod for detailed info +kubectl describe pod -n deer-flow -l app=deer-flow-sandbox +``` + +## Configuration Options + +### Resource Limits + +Edit `sandbox-deployment.yaml` to adjust resource limits: + +```yaml +resources: + requests: + cpu: 100m # Minimum CPU + memory: 256Mi # Minimum memory + limits: + cpu: 1000m # Maximum CPU (1 core) + memory: 1Gi # Maximum memory +``` + +### Scaling + +Adjust the number of replicas: + +```yaml +spec: + replicas: 3 # Run 3 sandbox pods +``` + +Or scale dynamically: + +```bash +kubectl scale deployment deer-flow-sandbox -n deer-flow --replicas=3 +``` + +### Health Checks + +The deployment includes readiness and liveness probes: + +- **Readiness Probe**: Checks if the pod is ready to serve traffic +- **Liveness Probe**: Restarts the pod if it becomes unhealthy + +Configure in `sandbox-deployment.yaml`: + +```yaml +readinessProbe: + httpGet: + path: /v1/sandbox + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +## Troubleshooting + +### Pod Not Starting + +Check pod status and events: + +```bash +kubectl describe pod -n deer-flow -l app=deer-flow-sandbox +``` + +Common issues: +- **ImagePullBackOff**: Docker image cannot be pulled + - Solution: Pre-pull image with `docker pull ` +- **Skills path not found**: HostPath doesn't exist + - Solution: Verify the skills path exists on the host +- **Resource constraints**: Not enough CPU/memory + - Solution: Adjust resource requests/limits + +### Service Not Accessible + +Verify the service is running: + +```bash +kubectl get service -n deer-flow +kubectl describe service deer-flow-sandbox -n deer-flow +``` + +Test connectivity from another pod: + +```bash +kubectl run test-pod -n deer-flow --rm -it --image=curlimages/curl -- \ + curl http://deer-flow-sandbox.deer-flow.svc.cluster.local:8080/v1/sandbox +``` + +### Check Logs + +View sandbox logs: + +```bash +# Follow logs in real-time +kubectl logs -n deer-flow -l app=deer-flow-sandbox -f + +# View logs from previous container (if crashed) +kubectl logs -n deer-flow -l app=deer-flow-sandbox --previous +``` + +### Health Check Failures + +If pods show as not ready: + +```bash +# Check readiness probe +kubectl get events -n deer-flow --sort-by='.lastTimestamp' + +# Exec into pod to debug +kubectl exec -it -n deer-flow -- /bin/sh +``` + +## Cleanup + +### Remove All Resources + +Using the setup script: + +```bash +./setup.sh --cleanup +``` + +Or manually: + +```bash +kubectl delete -f sandbox-deployment.yaml +kubectl delete -f sandbox-service.yaml +kubectl delete namespace deer-flow +``` + +### Remove Specific Resources + +```bash +# Delete only the deployment (keeps namespace and service) +kubectl delete deployment deer-flow-sandbox -n deer-flow + +# Delete pods (they will be recreated by deployment) +kubectl delete pods -n deer-flow -l app=deer-flow-sandbox +``` + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ DeerFlow Backend │ +│ (config.yaml: base_url configured) │ +└────────────────┬────────────────────────────┘ + │ HTTP requests + ↓ +┌─────────────────────────────────────────────┐ +│ Kubernetes Service (ClusterIP) │ +│ deer-flow-sandbox.deer-flow.svc:8080 │ +└────────────────┬────────────────────────────┘ + │ Load balancing + ↓ +┌─────────────────────────────────────────────┐ +│ Sandbox Pods (replicas) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Pod 1 │ │ Pod 2 │ │ Pod 3 │ │ +│ │ Port 8080│ │ Port 8080│ │ Port 8080│ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +└────────────────┬────────────────────────────┘ + │ Volume mount + ↓ +┌─────────────────────────────────────────────┐ +│ Host Skills Directory │ +│ /path/to/deer-flow/skills │ +└─────────────────────────────────────────────┘ +``` + +## Setup Script Reference + +### Command-Line Options + +```bash +./setup.sh [options] + +Options: + -h, --help Show help message + -c, --cleanup Remove all Kubernetes resources + -p, --skip-pull Skip pulling sandbox image + --image Use custom sandbox image + --skills-path Custom skills directory path + +Environment Variables: + SANDBOX_IMAGE Custom sandbox image + SKILLS_PATH Custom skills path + +Examples: + ./setup.sh # Use default settings + ./setup.sh --skills-path /custom/path # Use custom skills path + ./setup.sh --skip-pull --image custom:tag # Custom image, skip pull + SKILLS_PATH=/custom/path ./setup.sh # Use env variable +``` + +## Production Considerations + +### Security + +1. **Network Policies**: Restrict pod-to-pod communication +2. **RBAC**: Configure appropriate service account permissions +3. **Pod Security**: Enable pod security standards +4. **Image Security**: Scan images for vulnerabilities + +### High Availability + +1. **Multiple Replicas**: Run at least 3 replicas +2. **Pod Disruption Budget**: Prevent all pods from being evicted +3. **Node Affinity**: Distribute pods across nodes +4. **Resource Quotas**: Set namespace resource limits + +### Monitoring + +1. **Prometheus**: Scrape metrics from pods +2. **Logging**: Centralized log aggregation +3. **Alerting**: Set up alerts for pod failures +4. **Tracing**: Distributed tracing for requests + +### Storage + +For production, consider using PersistentVolume instead of hostPath: + +1. **Create PersistentVolume**: Define storage backend +2. **Create PersistentVolumeClaim**: Request storage +3. **Update Deployment**: Use PVC instead of hostPath + +See `skills-pv-pvc.yaml.bak` for reference implementation. + +## Next Steps + +After successful deployment: + +1. **Start Backend**: `make dev` or `make docker-start` +2. **Test Sandbox**: Create a conversation and execute code +3. **Monitor**: Watch pod logs and resource usage +4. **Scale**: Adjust replicas based on workload + +## Support + +For issues and questions: + +- Check troubleshooting section above +- Review pod logs: `kubectl logs -n deer-flow -l app=deer-flow-sandbox` +- See main project documentation: [../../README.md](../../README.md) +- Report issues on GitHub diff --git a/docker/k8s/namespace.yaml b/docker/k8s/namespace.yaml new file mode 100644 index 0000000..91b2a64 --- /dev/null +++ b/docker/k8s/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: deer-flow + labels: + app.kubernetes.io/name: deer-flow + app.kubernetes.io/component: sandbox diff --git a/docker/k8s/sandbox-deployment.yaml b/docker/k8s/sandbox-deployment.yaml new file mode 100644 index 0000000..0e1ca92 --- /dev/null +++ b/docker/k8s/sandbox-deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: deer-flow-sandbox + namespace: deer-flow + labels: + app.kubernetes.io/name: deer-flow + app.kubernetes.io/component: sandbox +spec: + replicas: 1 + selector: + matchLabels: + app: deer-flow-sandbox + template: + metadata: + labels: + app: deer-flow-sandbox + app.kubernetes.io/name: deer-flow + app.kubernetes.io/component: sandbox + spec: + containers: + - name: sandbox + image: enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest + ports: + - name: http + containerPort: 8080 + protocol: TCP + readinessProbe: + httpGet: + path: /v1/sandbox + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /v1/sandbox + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + volumeMounts: + - name: skills + mountPath: /mnt/skills + readOnly: true + securityContext: + privileged: false + allowPrivilegeEscalation: true + volumes: + - name: skills + hostPath: + # Path to skills directory on the host machine + # This will be replaced by setup.sh with the actual path + path: __SKILLS_PATH__ + type: Directory + restartPolicy: Always diff --git a/docker/k8s/sandbox-service.yaml b/docker/k8s/sandbox-service.yaml new file mode 100644 index 0000000..05075f4 --- /dev/null +++ b/docker/k8s/sandbox-service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: deer-flow-sandbox + namespace: deer-flow + labels: + app.kubernetes.io/name: deer-flow + app.kubernetes.io/component: sandbox +spec: + type: ClusterIP + clusterIP: None # Headless service for direct Pod DNS access + ports: + - name: http + port: 8080 + targetPort: 8080 + protocol: TCP + selector: + app: deer-flow-sandbox + # Enable DNS-based service discovery + # Pods will be accessible at: {pod-name}.deer-flow-sandbox.deer-flow.svc.cluster.local:8080 + publishNotReadyAddresses: false diff --git a/docker/k8s/setup.sh b/docker/k8s/setup.sh new file mode 100755 index 0000000..06cc83b --- /dev/null +++ b/docker/k8s/setup.sh @@ -0,0 +1,245 @@ +#!/bin/bash + +# Kubernetes Sandbox Initialization Script for Deer-Flow +# This script sets up the Kubernetes environment for the sandbox provider + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +# Default sandbox image +DEFAULT_SANDBOX_IMAGE="enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}╔════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ Deer-Flow Kubernetes Sandbox Setup ║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════════╝${NC}" +echo + +# Function to print status messages +info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if kubectl is installed +check_kubectl() { + info "Checking kubectl installation..." + if ! command -v kubectl &> /dev/null; then + error "kubectl is not installed. Please install kubectl first." + echo " - macOS: brew install kubectl" + echo " - Linux: https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/" + exit 1 + fi + success "kubectl is installed" +} + +# Check if Kubernetes cluster is accessible +check_cluster() { + info "Checking Kubernetes cluster connection..." + if ! kubectl cluster-info &> /dev/null; then + error "Cannot connect to Kubernetes cluster." + echo "Please ensure:" + echo " - Docker Desktop: Settings → Kubernetes → Enable Kubernetes" + echo " - Or OrbStack: Enable Kubernetes in settings" + echo " - Or Minikube: minikube start" + exit 1 + fi + success "Connected to Kubernetes cluster" +} + +# Apply Kubernetes resources +apply_resources() { + info "Applying Kubernetes resources..." + + # Determine skills path + SKILLS_PATH="${SKILLS_PATH:-${PROJECT_ROOT}/skills}" + info "Using skills path: ${SKILLS_PATH}" + + # Validate skills path exists + if [[ ! -d "${SKILLS_PATH}" ]]; then + warn "Skills path does not exist: ${SKILLS_PATH}" + warn "Creating directory..." + mkdir -p "${SKILLS_PATH}" + fi + + echo " → Creating namespace..." + kubectl apply -f "${SCRIPT_DIR}/namespace.yaml" + + echo " → Creating sandbox service..." + kubectl apply -f "${SCRIPT_DIR}/sandbox-service.yaml" + + echo " → Creating sandbox deployment with skills path: ${SKILLS_PATH}" + # Replace __SKILLS_PATH__ placeholder with actual path + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + sed "s|__SKILLS_PATH__|${SKILLS_PATH}|g" "${SCRIPT_DIR}/sandbox-deployment.yaml" | kubectl apply -f - + else + # Linux + sed "s|__SKILLS_PATH__|${SKILLS_PATH}|g" "${SCRIPT_DIR}/sandbox-deployment.yaml" | kubectl apply -f - + fi + + success "All Kubernetes resources applied" +} + +# Verify deployment +verify_deployment() { + info "Verifying deployment..." + + echo " → Checking namespace..." + kubectl get namespace deer-flow + + echo " → Checking service..." + kubectl get service -n deer-flow + + echo " → Checking deployment..." + kubectl get deployment -n deer-flow + + echo " → Checking pods..." + kubectl get pods -n deer-flow + + success "Deployment verified" +} + +# Pull sandbox image +pull_image() { + info "Checking sandbox image..." + + IMAGE="${SANDBOX_IMAGE:-$DEFAULT_SANDBOX_IMAGE}" + + # Check if image already exists locally + if docker image inspect "$IMAGE" &> /dev/null; then + success "Image already exists locally: $IMAGE" + return 0 + fi + + info "Pulling sandbox image (this may take a few minutes on first run)..." + echo " → Image: $IMAGE" + echo + + if docker pull "$IMAGE"; then + success "Image pulled successfully" + else + warn "Failed to pull image. Pod startup may be slow on first run." + echo " You can manually pull the image later with:" + echo " docker pull $IMAGE" + fi +} + +# Print next steps +print_next_steps() { + echo + echo -e "${BLUE}╔════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ Setup Complete! ║${NC}" + echo -e "${BLUE}╚════════════════════════════════════════════╝${NC}" + echo + echo -e "${YELLOW}To enable Kubernetes sandbox, add the following to backend/config.yaml:${NC}" + echo + echo -e "${GREEN}sandbox:${NC}" + echo -e "${GREEN} use: src.community.aio_sandbox:AioSandboxProvider${NC}" + echo -e "${GREEN} base_url: http://deer-flow-sandbox.deer-flow.svc.cluster.local:8080${NC}" + echo + echo + echo -e "${GREEN}Next steps:${NC}" + echo " make dev # Start backend and frontend in development mode" + echo " make docker-start # Start backend and frontend in Docker containers" + echo +} + +# Cleanup function +cleanup() { + if [[ "$1" == "--cleanup" ]] || [[ "$1" == "-c" ]]; then + info "Cleaning up Kubernetes resources..." + kubectl delete -f "${SCRIPT_DIR}/sandbox-deployment.yaml" --ignore-not-found=true + kubectl delete -f "${SCRIPT_DIR}/sandbox-service.yaml" --ignore-not-found=true + kubectl delete -f "${SCRIPT_DIR}/namespace.yaml" --ignore-not-found=true + success "Cleanup complete" + exit 0 + fi +} + +# Show help +show_help() { + echo "Usage: $0 [options]" + echo + echo "Options:" + echo " -h, --help Show this help message" + echo " -c, --cleanup Remove all Kubernetes resources" + echo " -p, --skip-pull Skip pulling sandbox image" + echo " --image Use custom sandbox image" + echo " --skills-path Custom skills directory path" + echo + echo "Environment variables:" + echo " SANDBOX_IMAGE Custom sandbox image (default: $DEFAULT_SANDBOX_IMAGE)" + echo " SKILLS_PATH Custom skills path (default: PROJECT_ROOT/skills)" + echo + echo "Examples:" + echo " $0 # Use default settings" + echo " $0 --skills-path /custom/path # Use custom skills path" + echo " SKILLS_PATH=/custom/path $0 # Use env variable" + echo + exit 0 +} + +# Parse arguments +SKIP_PULL=false +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_help + ;; + -c|--cleanup) + cleanup "$1" + ;; + -p|--skip-pull) + SKIP_PULL=true + shift + ;; + --image) + SANDBOX_IMAGE="$2" + shift 2 + ;; + --skills-path) + SKILLS_PATH="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +# Main execution +main() { + check_kubectl + check_cluster + + # Pull image first to avoid Pod startup timeout + if [[ "$SKIP_PULL" == false ]]; then + pull_image + fi + + apply_resources + verify_deployment + print_next_steps +} + +main diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index d2cced5..c37c418 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -16,7 +16,7 @@ http { # Upstream servers (using Docker service names) upstream gateway { - server api:8001; + server gateway:8001; } upstream langgraph { @@ -24,7 +24,7 @@ http { } upstream frontend { - server web:3000; + server frontend:3000; } server { diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 04c826b..941945f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -12,5 +12,11 @@ RUN pnpm config set store-dir ${PNPM_STORE_PATH} # Set working directory WORKDIR /app +# Copy frontend source code +COPY frontend ./frontend + +# Install dependencies +RUN sh -c "cd /app/frontend && pnpm install --frozen-lockfile" + # Expose Next.js dev server port EXPOSE 3000 diff --git a/scripts/docker.sh b/scripts/docker.sh index 21a305b..c0ad9bb 100755 --- a/scripts/docker.sh +++ b/scripts/docker.sh @@ -60,32 +60,14 @@ init() { echo "" - # Build containers + # Build containers (dependencies are installed during build) echo -e "${BLUE}Building containers...${NC}" + echo -e "${BLUE} - Frontend dependencies will be installed via Dockerfile${NC}" + echo -e "${BLUE} - Backend dependencies will be installed via Dockerfile${NC}" cd "$DOCKER_DIR" && PNPM_STORE_PATH="$PNPM_STORE" $COMPOSE_CMD build echo "" - # Install frontend dependencies - echo -e "${BLUE}Installing frontend dependencies...${NC}" - if ! (cd "$DOCKER_DIR" && PNPM_STORE_PATH="$PNPM_STORE" $COMPOSE_CMD run --rm -it --entrypoint "" web pnpm install --frozen-lockfile); then - echo -e "${YELLOW}Frontend dependencies installation failed or was interrupted${NC}" - exit 1 - fi - echo -e "${GREEN}✓ Frontend dependencies installed${NC}" - - echo "" - - # Install backend dependencies - echo -e "${BLUE}Installing backend dependencies...${NC}" - if ! (cd "$DOCKER_DIR" && $COMPOSE_CMD run --rm -it --entrypoint "" api uv sync); then - echo -e "${YELLOW}Backend dependencies installation failed or was interrupted${NC}" - exit 1 - fi - echo -e "${GREEN}✓ Backend dependencies installed${NC}" - - echo "" - echo "==========================================" echo -e "${GREEN} ✓ Docker initialization complete!${NC}" echo "==========================================" @@ -111,8 +93,8 @@ start() { echo " 📡 API Gateway: http://localhost:2026/api/*" echo " 🤖 LangGraph: http://localhost:2026/api/langgraph/*" echo "" - echo " 📋 View logs: make docker-dev-logs" - echo " 🛑 Stop: make docker-dev-stop" + echo " 📋 View logs: make docker-logs" + echo " 🛑 Stop: make docker-stop" echo "" } @@ -121,20 +103,24 @@ logs() { local service="" case "$1" in - --web) - service="web" + --frontend) + service="frontend" echo -e "${BLUE}Viewing frontend logs...${NC}" ;; - --api) - service="api" - echo -e "${BLUE}Viewing backend logs...${NC}" + --gateway) + service="gateway" + echo -e "${BLUE}Viewing gateway logs...${NC}" + ;; + --nginx) + service="nginx" + echo -e "${BLUE}Viewing nginx logs...${NC}" ;; "") echo -e "${BLUE}Viewing all logs...${NC}" ;; *) echo -e "${YELLOW}Unknown option: $1${NC}" - echo "Usage: $0 logs [--web|--api]" + echo "Usage: $0 logs [--frontend|--gateway]" exit 1 ;; esac @@ -176,8 +162,8 @@ help() { echo " start - Start all services in Docker (localhost:2026)" echo " restart - Restart all running Docker services" echo " logs [option] - View Docker development logs" - echo " --web View frontend logs only" - echo " --api View backend logs only" + echo " --frontend View frontend logs only" + echo " --gateway View gateway logs only" echo " stop - Stop Docker development services" echo " help - Show this help message" echo "" From f8e4fe05b257bf043488f4942210bd94b04fcd84 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 22:57:54 +0800 Subject: [PATCH 3/9] Strip citation prefix in citation badges --- .../src/components/workspace/citations/citation-link.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/workspace/citations/citation-link.tsx b/frontend/src/components/workspace/citations/citation-link.tsx index 72894e2..6aab8c2 100644 --- a/frontend/src/components/workspace/citations/citation-link.tsx +++ b/frontend/src/components/workspace/citations/citation-link.tsx @@ -17,7 +17,10 @@ export function CitationLink({ const domain = extractDomain(href ?? ""); // Priority: children > domain - const childrenText = typeof children === "string" ? children : null; + const childrenText = + typeof children === "string" + ? children.replace(/^citation:\s*/i, "") + : null; const isGenericText = childrenText === "Source" || childrenText === "来源"; const displayText = (!isGenericText && childrenText) ?? domain; From df3668ecd50eacdd8f59d801522ab650608ab3f2 Mon Sep 17 00:00:00 2001 From: Henry Li Date: Mon, 9 Feb 2026 23:28:36 +0800 Subject: [PATCH 4/9] fix: eslint --- frontend/src/app/workspace/chats/[thread_id]/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index e2ae9f8..c098626 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -1,6 +1,7 @@ "use client"; import type { Message } from "@langchain/langgraph-sdk"; +import type { UseStream } from "@langchain/langgraph-sdk/react"; import { FilesIcon, XIcon } from "lucide-react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -113,7 +114,7 @@ export default function ChatPage() { }); } }, - }); + }) as unknown as UseStream; useEffect(() => { if (thread.isLoading) setFinalState(null); }, [thread.isLoading]); From 6109216d54b7816556725f2e8e3564653fd15122 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Tue, 10 Feb 2026 00:18:33 +0800 Subject: [PATCH 5/9] fix: citations prompt --- backend/src/agents/lead_agent/prompt.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py index 2660170..fe03d14 100644 --- a/backend/src/agents/lead_agent/prompt.py +++ b/backend/src/agents/lead_agent/prompt.py @@ -259,9 +259,17 @@ You have access to skills that provide optimized workflows for specific tasks. E - Clear and Concise: Avoid over-formatting unless requested - Natural Tone: Use paragraphs and prose, not bullet points by default - Action-Oriented: Focus on delivering results, not explaining processes -- Citations: Use `[citation:Title](URL)` format for external sources + +- When to Use: After web_search, include citations if applicable +- Format: Use Markdown link format `[citation:TITLE](URL)` +- Example: +```markdown +The key AI trends for 2026 include enhanced reasoning capabilities and multimodal integration [citation:AI Trends 2026](https://techcrunch.com/ai-trends). Recent breakthroughs in language models have also accelerated progress [citation:OpenAI Research](https://openai.com/research). +``` + + - **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess {subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks. From f87d5678f3bd000e1e4d16e14f52cfe1fbfb1eaa Mon Sep 17 00:00:00 2001 From: LofiSu Date: Tue, 10 Feb 2026 12:15:37 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E9=9D=A2UI=E5=92=8C=E5=9B=BD=E9=99=85?= =?UTF-8?q?=E5=8C=96=E6=94=AF=E6=8C=81=20/=20Improve=20settings=20pages=20?= =?UTF-8?q?UI=20and=20i18n=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 rehype-raw 依赖以支持在 markdown 中渲染 HTML Add rehype-raw dependency to support HTML rendering in markdown - 重构 memory-settings-page,提取 formatMemorySection 函数减少重复代码 Refactor memory-settings-page by extracting formatMemorySection function to reduce code duplication - 改进空状态显示,使用 HTML span 标签替代 markdown 斜体,提供更好的样式控制 Improve empty state display by using HTML span tags instead of markdown italics for better style control - 为 skill-settings-page 添加完整的国际化支持,替换硬编码的英文文本 Add complete i18n support for skill-settings-page, replacing hardcoded English text - 更新国际化文件,添加技能设置页面的空状态文本(中英文) Update i18n files with empty state text for skill settings page (both Chinese and English) - 在 streamdown 插件配置中添加 rehypeRaw 以支持 HTML 渲染 Add rehypeRaw to streamdown plugins configuration to support HTML rendering Co-authored-by: Cursor --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 3 + .../settings/memory-settings-page.tsx | 102 ++++++++++-------- .../settings/skill-settings-page.tsx | 8 +- frontend/src/core/i18n/locales/en-US.ts | 6 +- frontend/src/core/i18n/locales/types.ts | 3 + frontend/src/core/i18n/locales/zh-CN.ts | 6 +- frontend/src/core/streamdown/plugins.ts | 2 + 8 files changed, 82 insertions(+), 49 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index e936c34..46ca46a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,6 +73,7 @@ "react-dom": "^19.0.0", "react-resizable-panels": "^4.4.1", "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "shiki": "3.15.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 9fc1f0c..cb04b35 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: rehype-katex: specifier: ^7.0.1 version: 7.0.1 + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 diff --git a/frontend/src/components/workspace/settings/memory-settings-page.tsx b/frontend/src/components/workspace/settings/memory-settings-page.tsx index a5225e6..831aa38 100644 --- a/frontend/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend/src/components/workspace/settings/memory-settings-page.tsx @@ -31,6 +31,26 @@ function confidenceToLevelKey(confidence: unknown): { return { key: "normal", value }; } +function formatMemorySection( + title: string, + summary: string, + updatedAt: string | undefined, + t: ReturnType["t"], +): string { + const content = + summary.trim() || + `${t.settings.memory.markdown.empty}`; + return [ + `### ${title}`, + content, + "", + updatedAt && + `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(updatedAt)}\``, + ] + .filter(Boolean) + .join("\n"); +} + function memoryToMarkdown( memory: UserMemory, t: ReturnType["t"], @@ -44,65 +64,61 @@ function memoryToMarkdown( parts.push(`\n## ${t.settings.memory.markdown.userContext}`); parts.push( - [ - `### ${t.settings.memory.markdown.work}`, - memory.user.workContext.summary || "-", - "", - memory.user.workContext.updatedAt && - `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.user.workContext.updatedAt)}\``, - ].join("\n"), + formatMemorySection( + t.settings.memory.markdown.work, + memory.user.workContext.summary, + memory.user.workContext.updatedAt, + t, + ), ); parts.push( - [ - `### ${t.settings.memory.markdown.personal}`, - memory.user.personalContext.summary || "-", - "", - memory.user.personalContext.updatedAt && - `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.user.personalContext.updatedAt)}\``, - ].join("\n"), + formatMemorySection( + t.settings.memory.markdown.personal, + memory.user.personalContext.summary, + memory.user.personalContext.updatedAt, + t, + ), ); parts.push( - [ - `### ${t.settings.memory.markdown.topOfMind}`, - memory.user.topOfMind.summary || "-", - "", - memory.user.topOfMind.updatedAt && - `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.user.topOfMind.updatedAt)}\``, - ].join("\n"), + formatMemorySection( + t.settings.memory.markdown.topOfMind, + memory.user.topOfMind.summary, + memory.user.topOfMind.updatedAt, + t, + ), ); parts.push(`\n## ${t.settings.memory.markdown.historyBackground}`); parts.push( - [ - `### ${t.settings.memory.markdown.recentMonths}`, - memory.history.recentMonths.summary || "-", - "", - memory.history.recentMonths.updatedAt && - `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.history.recentMonths.updatedAt)}\``, - ].join("\n"), + formatMemorySection( + t.settings.memory.markdown.recentMonths, + memory.history.recentMonths.summary, + memory.history.recentMonths.updatedAt, + t, + ), ); parts.push( - [ - `### ${t.settings.memory.markdown.earlierContext}`, - memory.history.earlierContext.summary || "-", - "", - memory.history.earlierContext.updatedAt && - `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.history.earlierContext.updatedAt)}\``, - ].join("\n"), + formatMemorySection( + t.settings.memory.markdown.earlierContext, + memory.history.earlierContext.summary, + memory.history.earlierContext.updatedAt, + t, + ), ); parts.push( - [ - `### ${t.settings.memory.markdown.longTermBackground}`, - memory.history.longTermBackground.summary || "-", - "", - memory.history.longTermBackground.updatedAt && - `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.history.longTermBackground.updatedAt)}\``, - ].join("\n"), + formatMemorySection( + t.settings.memory.markdown.longTermBackground, + memory.history.longTermBackground.summary, + memory.history.longTermBackground.updatedAt, + t, + ), ); parts.push(`\n## ${t.settings.memory.markdown.facts}`); if (memory.facts.length === 0) { - parts.push(`_${t.settings.memory.markdown.empty}_`); + parts.push( + `${t.settings.memory.markdown.empty}`, + ); } else { parts.push( [ diff --git a/frontend/src/components/workspace/settings/skill-settings-page.tsx b/frontend/src/components/workspace/settings/skill-settings-page.tsx index 24398fa..c564256 100644 --- a/frontend/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend/src/components/workspace/settings/skill-settings-page.tsx @@ -115,20 +115,20 @@ function SkillSettingsList({ } function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { + const { t } = useI18n(); return ( - No agent skill yet + {t.settings.skills.emptyTitle} - Put your agent skill folders under the `/skills/custom` folder under - the root folder of DeerFlow. + {t.settings.skills.emptyDescription} - + ); diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index ac53d66..d60e817 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -243,7 +243,7 @@ export const enUS: Translations = { longTermBackground: "Long-term background", updatedAt: "Updated at", facts: "Facts", - empty: "Empty", + empty: "empty", table: { category: "Category", confidence: "Confidence", @@ -282,6 +282,10 @@ export const enUS: Translations = { description: "Manage the configuration and enabled status of the agent skills.", createSkill: "Create skill", + emptyTitle: "No agent skill yet", + emptyDescription: + "Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.", + emptyButton: "Create Your First Skill", }, notification: { title: "Notification", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 843f517..7213efa 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -225,6 +225,9 @@ export interface Translations { title: string; description: string; createSkill: string; + emptyTitle: string; + emptyDescription: string; + emptyButton: string; }; notification: { title: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 4f03539..b3b2e1b 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -238,7 +238,7 @@ export const zhCN: Translations = { longTermBackground: "长期背景", updatedAt: "更新于", facts: "事实", - empty: "(空)", + empty: "空", table: { category: "类别", confidence: "置信度", @@ -275,6 +275,10 @@ export const zhCN: Translations = { title: "技能", description: "管理 Agent Skill 配置和启用状态。", createSkill: "新建技能", + emptyTitle: "还没有技能", + emptyDescription: + "将你的 Agent Skill 文件夹放在 DeerFlow 根目录下的 `/skills/custom` 文件夹中。", + emptyButton: "创建你的第一个技能", }, notification: { title: "通知", diff --git a/frontend/src/core/streamdown/plugins.ts b/frontend/src/core/streamdown/plugins.ts index d829a53..e921403 100644 --- a/frontend/src/core/streamdown/plugins.ts +++ b/frontend/src/core/streamdown/plugins.ts @@ -1,4 +1,5 @@ import rehypeKatex from "rehype-katex"; +import rehypeRaw from "rehype-raw"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import type { StreamdownProps } from "streamdown"; @@ -11,6 +12,7 @@ export const streamdownPlugins = { [remarkMath, { singleDollarTextMath: true }], ] as StreamdownProps["remarkPlugins"], rehypePlugins: [ + rehypeRaw, [rehypeKatex, { output: "html" }], ] as StreamdownProps["rehypePlugins"], }; From cc88823a64d74d063dcf4cbdf3861f4e31e0a255 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Tue, 10 Feb 2026 12:29:14 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:memory=20=E4=B8=BA=E7=A9=BA=E6=97=B6i18?= =?UTF-8?q?n=E5=AD=97=E4=BD=93=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/core/i18n/locales/en-US.ts | 2 +- frontend/src/core/i18n/locales/zh-CN.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index d60e817..1bc60a9 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -243,7 +243,7 @@ export const enUS: Translations = { longTermBackground: "Long-term background", updatedAt: "Updated at", facts: "Facts", - empty: "empty", + empty: "(empty)", table: { category: "Category", confidence: "Confidence", diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index b3b2e1b..a3d399b 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -238,7 +238,7 @@ export const zhCN: Translations = { longTermBackground: "长期背景", updatedAt: "更新于", facts: "事实", - empty: "空", + empty: "(空)", table: { category: "类别", confidence: "置信度", From b3a1f018ab56626accb8d8f993f0d6087fe2faa8 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Tue, 10 Feb 2026 12:39:44 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E5=90=8E=E8=BE=93=E5=85=A5=E6=A1=86=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E7=BC=96=E8=BE=91=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:点击新建技能按钮后,对话框中预设的文字无法删除或修改 原因:useEffect 依赖项包含 promptInputController.textInput,该对象在每次输入时都会重新创建,导致 useEffect 重复执行并覆盖用户输入 解决:使用 useRef 保存 setInput 方法,并跟踪已设置的初始值,确保 useEffect 只在初始值变化时执行一次 Co-authored-by: Cursor --- .../src/app/workspace/chats/[thread_id]/page.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index c098626..fb66c38 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -4,7 +4,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import type { UseStream } from "@langchain/langgraph-sdk/react"; import { FilesIcon, XIcon } from "lucide-react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { usePromptInputController } from "@/components/ai-elements/prompt-input"; @@ -63,10 +63,14 @@ export default function ChatPage() { } return t.inputBox.createSkillPrompt; }, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]); + const lastInitialValueRef = useRef(undefined); + const setInputRef = useRef(promptInputController.textInput.setInput); + setInputRef.current = promptInputController.textInput.setInput; useEffect(() => { - if (inputInitialValue) { + if (inputInitialValue && inputInitialValue !== lastInitialValueRef.current) { + lastInitialValueRef.current = inputInitialValue; setTimeout(() => { - promptInputController.textInput.setInput(inputInitialValue); + setInputRef.current(inputInitialValue); const textarea = document.querySelector("textarea"); if (textarea) { textarea.focus(); @@ -75,7 +79,7 @@ export default function ChatPage() { } }, 100); } - }, [inputInitialValue, promptInputController.textInput]); + }, [inputInitialValue]); const isNewThread = useMemo( () => threadIdFromPath === "new", [threadIdFromPath], From c8f7bc28e16b7848eea23a4c18dbbfacc1c6b7a0 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Tue, 10 Feb 2026 12:41:58 +0800 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E5=90=8D=E7=A7=B0=E5=86=B2=E7=AA=81=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=9A=84=E8=AF=A6=E7=BB=86=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 记录 public 和 custom 技能同名冲突问题的解决方案 - 详细说明所有代码改动(后端配置、API、前端) - 包含配置格式变更、API 变更说明 - 标注已知问题暂时保留,待后续版本修复 - 提供测试建议和回滚方案 相关改动: - 使用组合键 {category}:{name} 存储配置 - API 支持可选的 category 查询参数 - 添加类别内重复技能名称检查 - 前端传递 category 参数确保唯一性 Co-authored-by: Cursor --- docs/SKILL_NAME_CONFLICT_FIX.md | 865 ++++++++++++++++++++++++++++++++ 1 file changed, 865 insertions(+) create mode 100644 docs/SKILL_NAME_CONFLICT_FIX.md diff --git a/docs/SKILL_NAME_CONFLICT_FIX.md b/docs/SKILL_NAME_CONFLICT_FIX.md new file mode 100644 index 0000000..2103401 --- /dev/null +++ b/docs/SKILL_NAME_CONFLICT_FIX.md @@ -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/src/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/src/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/src/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) => ( + + {/* ... */} + + enableSkill({ skillName: skill.name, enabled: checked }) + } + /> + + ))} +``` + +**修改后**: +```typescript +{filteredSkills.length > 0 && + filteredSkills.map((skill) => ( + + {/* ... */} + + enableSkill({ + skillName: skill.name, + enabled: checked, + category: skill.category, + }) + } + /> + + ))} +``` + +**改动说明**: +- **关键改动**: 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/src/config/extensions_config.py`: +1 方法,修改 1 方法 + - `backend/src/skills/loader.py`: +重复检查逻辑 + - `backend/src/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