Merge branch 'ji' into master

合并 ji 的功能:开放问答接口(/api/open/chat[/stream] 回传生成文件 + 临时不留存)、
技能地址管理、智能体详情「最近对话」等。
冲突 frontend StrategyRoutes.tsx 已解决:同时保留 admin/roundtable-diagnostics(master)
与 admin/skill-addresses(ji) 两个管理员路由。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-22 19:39:35 +08:00
commit fbec222eb4
38 files changed
+3365 -94

No files matched your search

+67 -3
View File
@@ -243,6 +243,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
| **Open Chat** (`/api/open/chat`) | **Unauthenticated** open Q&A entry for external systems (the `/api/open/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES` + CSRF-exempt in `csrf_middleware.should_check_csrf`). `POST /chat` `{message \| messages[], model_name?, agent_name?, agent_id?, thinking_enabled=false, strip_think=true, thread_id?, recursion_limit=100}` → resolves the agent by **Chinese display name** (`agent_name`, matched against each `.deer-flow/agents/*/config.yaml` `name` via `_resolve_agent_id`; explicit `agent_id` wins if given), builds the lead agent via `make_lead_agent` (so the chosen **agent's** SOUL/tools/skills all apply), `ainvoke`s it once (stateless — no checkpointer, no thread persistence; runs under the `"default"` user bucket), and returns **only the final** `{answer, model_name, agent_name, agent_id, thinking_enabled, thread_id}` (no intermediate tool-call/process). **`POST /chat/stream`** is the **SSE streaming variant** (same request body) for **long tasks** — the blocking `/chat` produces no downstream bytes for the whole run, so a front nginx `proxy_read_timeout` (default 60s) cuts it off with a **504**; the stream keeps the connection warm (a `: keepalive` SSE comment every `_HEARTBEAT_SECONDS=15`s via a background producer-task + queue, plus `event: delta` AI-text increments and `event: progress`), and ends with **`event: result`** carrying the same final `{answer, …}` JSON (or `event: error`). Clients only need to read `event: result`; intermediate events are ignorable. Both endpoints share `_build_run_context` (config/agent/thread). `GET /agents` lists `{name, id}` so callers know what `agent_name` to send. **Thinking off is reliable**: when `thinking_enabled=false` the route also sets `thinking_force_disabled=true` so the lead agent calls `create_chat_model(force_disable_thinking=True)`, which injects **three** OpenAI-compatible "thinking off" shapes into `extra_body` — top-level `enable_thinking=false` (**SiliconFlow / DashScope hybrid models** like `deepseek-ai/DeepSeek-V4-*` on `api.siliconflow.cn` — the knob the nested `chat_template_kwargs` shape does NOT reach), `chat_template_kwargs.enable_thinking=false` (vLLM/Qwen native), and `thinking.type="disabled"` (Zhipu GLM) — even for models that only declare `supports_thinking: true`. For the **web-UI path** (which sends `thinking_enabled=false` but NOT `force_disable`), SiliconFlow models need `when_thinking_disabled: {extra_body: {enable_thinking: false}}` in their `config.yaml` entry (added to `deepseek-ai/DeepSeek-V4-Flash`) for thinking-off to take effect. Sets `is_scheduled_run=true` internally to skip the auto-title LLM call. **Generated-file return (`include_files=true`, default on)**: an agent that produces its report as a file (writes to `/mnt/user-data/outputs/` + `present_files`) returns only a short summary in `answer`; to recover the real deliverable both endpoints now also return `files: [{name, virtual_path, encoding(text|base64), content}]` — `_collect_output_files` scans the run's `state['thread_data']['outputs_path']` (the open thread is stateless so artifacts can't be fetched by thread, but the endpoint runs server-side and the sandbox already wrote the files to disk), text decoded as utf-8 else base64, capped by `_MAX_OUTPUT_FILES=20` / `_MAX_OUTPUT_FILE_BYTES=5MB`. `/chat` puts `files` on the response; `/chat/stream` puts it in the `event: result` payload. A run with no answer **and** no files now 502s ("…文本答案或文件"). Router `app/gateway/routers/open_chat.py`; standalone caller `test_open_chat_api.py` (repo root, streaming by default); tests `tests/test_open_chat.py`. |
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta — incl. `chain_title`, the business-chain name used this session, cheaply regex-extracted from the in-memory `step2` blob in `_draft_to_meta`/`_extract_chain_title`; null = no chain / 自由模式; surfaced on the 第一步「分析记录」history card + sidebar list instead of the old「第 X 步」); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
@@ -393,7 +394,10 @@ query-time type, `recommended_question`.
Sync only ever adds/removes `origin='position'` rows, so a user's own
favorites/subscriptions are never clobbered. Default sets (no binding for a
type): agents = built-ins (`user_id IS NULL`); skills = empty (square stays
full, nothing added to 我的); scheduled tasks = empty; questions = unrestricted.
full, nothing added to 我的); scheduled tasks = empty; questions = **untagged
only** (默认岗位 / 无标签绑定 只看到未打标的题目;打标的题目只在绑定了对应标签的
岗位展示 — the router interprets `resolve_visible → None` as "show only untagged",
not "show everything").
`origin`/`position_id` columns were added to `agent_favorites` and
`scheduled_task_subscriptions` — see migration `20260616_01` +
`scripts/sql/column_additions_mysql.sql`. The running app also auto-adds these
@@ -415,8 +419,12 @@ unassigned users inherit, else `null`); powers the top-bar 岗位 label
changes: `GET /api/recommended-questions` is now
filtered by the caller's position (`?all=1` bypasses for admin management) and
admin can tag questions via `/api/tags/assignments/recommended_question/{id}`.
Each listed question now carries its assigned `tags` (bulk-attached via
`tag_store.list_assignments`), and the list accepts a repeatable `tag_ids`
**Default-position rule**: tags are bulk-attached to **all** questions first,
then the visibility filter applies — a position binding recommended_question 标签
sees only those tagged questions; a 默认岗位 / 无绑定 (`resolve_visible → None`)
sees only the **untagged** questions (打标的题目不再泄漏到默认岗位). `?all=1`
(admin management) skips this filter and returns every question with its tags.
The list also accepts a repeatable `tag_ids`
OR-filter — so the 推荐问题配置 page can display per-question 标签 and search by
them (frontend `RecommendedQuestionsPage` + `TagSearchBar`). Tests:
`tests/test_recommended_questions_tags.py`.
@@ -520,6 +528,61 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (????: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` ? an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (? `None`). The single endpoint maps that to `502 ?????,?????????:?`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N ?????????". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI ??" plus an **admin-only** header "???????" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
#### Skill Address Management (技能地址管理 — URL/IP 批量替换)
Admin-only maintenance tool to find every `http(s)://` URL and IPv4 (`ip[:port]`)
address in the **running** skills (`skills/{public,custom}`, all `.md` + `.py`,
recursing into `scripts/`/`references/`/`templates/`) and rewrite them in bulk —
e.g. swapping a UAT endpoint for production across all skills at once. The same
address commonly lives in both a skill's `SKILL.md` and its `scripts/*.py`, so
results are **aggregated by unique address** with every occurrence (`file:line` +
snippet) and replaced together.
- **Pure engine** — `deerflow/skills/address_scan.py` (harness, no `app.*`):
`find_spans` / `scan_occurrences` (URL regex with trailing-punctuation strip;
IPv4 with 0–255 octet check + optional port; an IP inside a URL is *not*
separately surfaced), `apply_replacements` (**substring-safe**: re-detects exact
token spans and only rewrites a span whose text equals a requested `from`, so
`1.2.3.4` is never rewritten inside `11.2.3.40`), `infer_kind`,
`is_valid_replacement`. Tested in `tests/test_skill_addresses.py`.
- **Router** — `app/gateway/routers/skill_addresses.py` (`/api/skill-addresses`,
**admin only**). **Availability at scale (150+ skills):** every filesystem
walk/read/write runs via `asyncio.to_thread` so the event loop is never blocked.
Routes: `GET /scan` (aggregated, one-shot) + `GET /scan/stream` (per-skill NDJSON
progress `{type:start|progress|result}`); `POST /preview` (dry-run diff +
validation warnings); `POST /apply` + `POST /apply/stream` (per-file NDJSON
progress); `GET /backups`; `POST /rollback/{backup_id}`. **Apply safety:**
optimistic lock via `expected_total` (409 if files changed since the scan),
path-guard (`is_relative_to` the skills root), auto disk backup to
`{skills_root}/.address-edits/{timestamp}/` (the `.`-prefixed dir is excluded
from scans), then `refresh_skills_system_prompt_cache_async()` so edits take
effect immediately. Registered in `app.py` after `sensitive_words.router`.
**Per-edit history:** each apply also writes a sibling manifest
`{skills_root}/.address-edits/{timestamp}.manifest.json` (best-effort) capturing
`{created_at, replaced_total, replacements:[{from,to,kind}], changed_files}`; the
sibling-not-child placement keeps it out of both the restore set and the file
count. `GET /backups` returns each record enriched with `replaced_total` +
`replacements` (from the manifest; falls back to dir mtime when absent), and
`POST /rollback/{backup_id}` restores a single record — so the UI shows a full
modification history and rolls back any one entry independently.
- **Frontend** — the panel is the reusable `SkillAddressesPanel` (exported from
`pages/SkillAddressesPage.tsx`; its default export keeps the standalone route
`/page/strategy/admin/skill-addresses` as a direct-link fallback). It is
**embedded as a「技能地址管理」tab inside the admin「技能管理」page**
`pages/AdminSkillsPage.tsx` (Tabs: 技能列表 + 技能地址管理; reached from the
「技能管理」item in the「设置和更多」dropdown `workspace-nav-menu.tsx` →
`/page/workspace/admin/skills`) — it is no longer a separate entry in the
`components/page-sidebar.tsx` settings dropdown. The panel:
auto-scan on open with a **progress bar** (扫描逐技能 / 应用逐文件 X/N), URL/IP
type filter, search, a「片段批量套用」helper (e.g. all `uat.4.cn` → `prod.4.cn`),
per-address「替换为」input with expandable occurrences, inline 预览 panel, 应用,
**multi-keyword search** (space-separated, all-must-hit), and a **修改记录**
(modification history) panel — each apply is one record (time / which addresses
changed from→to / file & replacement counts) with a per-record 回退此次
(single-record rollback, with inline confirm). Client + NDJSON stream parsing in
`strategy-components/api/skill-addresses.ts`. Scope is the running skills only;
the `deploy/minimal/skills/` copy is not touched (sync separately).
#### Skill Compression (技能压缩)
When the skill catalogue grows large, injecting every enabled skill full-text
@@ -615,6 +678,7 @@ scheduler runs once per deployment. Per run:
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
- Supports `thinking_enabled` flag with per-model `when_thinking_enabled` overrides
- `force_disable_thinking=True` (kwarg) hard-disables thinking even when the model config declares **no** `when_thinking_*`/`thinking` shape — many internal vLLM/Qwen models only set `supports_thinking: true`, so `thinking_enabled=False` alone injects nothing and they keep reasoning on. The flag injects both OpenAI-compatible disable shapes (`extra_body.chat_template_kwargs.enable_thinking=false` for vLLM/Qwen + `extra_body.thinking.type="disabled"` for Zhipu GLM), only for classes that accept `extra_body`. Mirrors `TitleMiddleware._force_disable_thinking`. The lead agent reads the runtime flag `thinking_force_disabled` and forwards it (used by `/api/open/chat`)
- Supports vLLM-style thinking toggles via `when_thinking_enabled.extra_body.chat_template_kwargs.enable_thinking` for Qwen reasoning models, while normalizing legacy `thinking` configs for backward compatibility
- Supports `supports_vision` flag for image understanding models
- Config values starting with `$` resolved as environment variables
@@ -50,6 +50,7 @@ from app.gateway.routers import (
models,
multi_agent,
notifications,
open_chat,
parallel_agent_tasks,
parallel_agents,
positions,
@@ -69,6 +70,7 @@ from app.gateway.routers import (
runs,
scheduled_tasks,
sensitive_words,
skill_addresses,
skills,
suggestions,
system_settings,
@@ -1359,6 +1361,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
app.include_router(sensitive_words.router)
# Skill URL/IP batch replace (技能地址管理) at /api/skill-addresses
app.include_router(skill_addresses.router)
# 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
app.include_router(dashboard_sessions.router)
@@ -1456,6 +1461,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router)
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
app.include_router(open_chat.router)
# --- ?????????? /page/workspace/roundtable/planning?---
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
# ??? frontend-web/docs/multi-agent-backend-dev.md
@@ -35,6 +35,9 @@ _PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/redoc",
"/openapi.json",
"/api/public/",
# 开放问答接口:无需登录即可指定模型 + 智能体做问答(见 routers/open_chat.py)。
# 以 "default" 用户桶运行,只暴露受控的问答能力。
"/api/open/",
)
# Exact auth paths that are public (login/register/status check).
@@ -52,6 +52,10 @@ def should_check_csrf(request: Request) -> bool:
# PUT/POST returns 403 even though auth was bypassed earlier in the chain.
if path.startswith("/api/public/"):
return False
# 开放问答接口同样是匿名、无会话的(见 auth_middleware._PUBLIC_PATH_PREFIXES),
# 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。
if path.startswith("/api/open/"):
return False
return True
@@ -0,0 +1,432 @@
"""开放问答接口(无需登录)。
给外部系统一个最简单的 HTTP 入口来「调用本项目的智能体做问答」:
- 可指定 **模型**(``model_name``,对应 ``config.yaml`` 中 ``models[]`` 的 name)。
- 可指定 **智能体**(``agent_id``,对应 ``.deer-flow/agents/<id>/``;留空 = 默认 lead agent)。
- 可关闭 **大模型思考模式**(``thinking_enabled=false``,默认即关闭)。内网 vLLM/Qwen
模型仅靠 ``thinking_enabled=False`` 往往关不掉思考(其 config 只声明
``supports_thinking: true``),本接口在关闭思考时会把
``thinking_force_disabled`` 一并下发,经
``create_chat_model(force_disable_thinking=True)`` 强制注入
``extra_body.chat_template_kwargs.enable_thinking=false``(及 GLM 的
``thinking.type=disabled``)——与「生成标题」时关闭思考的做法一致。
该路由前缀 ``/api/open/`` 已在 ``auth_middleware`` 白名单里,**绕过登录鉴权**,
以 ``"default"`` 用户桶运行,只暴露受控的问答能力(无文件/线程持久化)。
请求示例::
POST /api/open/chat
{
"message": "用一句话介绍一下你自己",
"model_name": "deepseek-ai/DeepSeek-V4-Flash",
"agent_id": null,
"thinking_enabled": false
}
返回::
{"answer": "...", "model_name": "...", "agent_id": null,
"thinking_enabled": false, "thread_id": "open-...."}
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import re
import shutil
import uuid
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from deerflow.agents.lead_agent.agent import make_lead_agent
from deerflow.config.agents_config import list_custom_agents, validate_agent_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/open", tags=["open"])
_THINK_TAG_RE = re.compile(r"<think>[\s\S]*?</think>", re.IGNORECASE)
class OpenChatMessage(BaseModel):
"""多轮历史中的一条消息。"""
role: Literal["user", "assistant", "system"] = "user"
content: str
class OpenChatRequest(BaseModel):
"""开放问答请求体。
``message`` 与 ``messages`` 二选一:单轮直接传 ``message``;需要带上下文时传
``messages``(完整历史,本接口本身无状态、不落库)。
"""
message: str | None = Field(default=None, description="单轮问题文本;与 messages 二选一")
messages: list[OpenChatMessage] | None = Field(default=None, description="多轮历史;与 message 二选一")
model_name: str | None = Field(default=None, description="models[] 中的模型名;留空用默认模型")
agent_name: str | None = Field(default=None, description="智能体名称(中文显示名);留空用默认 lead agent")
agent_id: str | None = Field(default=None, description="智能体 id;一般不用,优先级高于 agent_name")
thinking_enabled: bool = Field(default=False, description="是否开启思考;默认 False = 关闭思考(内网模型也会被强制关闭)")
strip_think: bool = Field(default=True, description="关闭思考时,从答案里剥离残留的 <think>…</think> 块")
thread_id: str | None = Field(default=None, description="可选;仅用于文件隔离,留空自动生成")
recursion_limit: int = Field(default=100, ge=1, le=300, description="智能体单次问答的最大步数")
include_files: bool = Field(default=True, description="是否随结果回传智能体在 outputs 目录生成的文件(名称+内容)")
class OpenChatFile(BaseModel):
"""智能体本次运行在 /mnt/user-data/outputs 下生成的一个文件。"""
name: str # outputs/ 下的相对路径,如 "报告.md"
virtual_path: str # 沙箱虚拟路径,如 "/mnt/user-data/outputs/报告.md"
encoding: Literal["text", "base64"] = "text" # text=可直接用的文本;base64=二进制需解码
content: str
class OpenChatResponse(BaseModel):
answer: str
model_name: str | None
agent_name: str | None
agent_id: str | None
thinking_enabled: bool
thread_id: str
files: list[OpenChatFile] = Field(default_factory=list, description="本次生成的输出文件(含内容)")
def _resolve_agent_id(agent_id: str | None, agent_name: str | None) -> str | None:
"""把 agent_id / 中文名称 解析成运行 id。
优先级:显式 agent_id > agent_name(按 .deer-flow/agents/*/config.yaml 的 name 匹配)。
都没传 = None(默认 lead agent)。名称匹配先精确、再忽略大小写/首尾空格。
"""
if agent_id and agent_id.strip():
try:
return validate_agent_id(agent_id)
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"非法的 agent_id:{exc}") from exc
if not agent_name or not agent_name.strip():
return None
target = agent_name.strip()
agents = list_custom_agents()
for a in agents:
if (a.name or "").strip() == target:
return a.id
lowered = target.lower()
for a in agents:
if (a.name or "").strip().lower() == lowered:
return a.id
raise HTTPException(status_code=404, detail=f"未找到名称为「{agent_name}」的智能体。")
def _build_lc_messages(body: OpenChatRequest) -> list[BaseMessage]:
"""把请求体转成 LangChain 消息列表。"""
if body.messages:
out: list[BaseMessage] = []
for m in body.messages:
text = (m.content or "").strip()
if not text:
continue
if m.role == "assistant":
out.append(AIMessage(content=m.content))
elif m.role == "system":
out.append(SystemMessage(content=m.content))
else:
out.append(HumanMessage(content=m.content))
if out:
return out
if body.message and body.message.strip():
return [HumanMessage(content=body.message)]
raise HTTPException(status_code=422, detail="必须提供非空的 'message' 或 'messages'。")
def _extract_text(content: Any) -> str:
"""从 AIMessage.content(str 或 block 列表)抽取纯文本。"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
text_value = block.get("text")
if isinstance(text_value, str):
parts.append(text_value)
return "".join(parts) if parts else ""
return ""
def _extract_answer(result: Any, *, strip_think: bool) -> str:
"""从 ainvoke 的最终状态里取最后一条 AI 文本。"""
messages = (result or {}).get("messages") if isinstance(result, dict) else None
answer = ""
if isinstance(messages, list):
for msg in reversed(messages):
if isinstance(msg, AIMessage):
text = _extract_text(msg.content).strip()
if text:
answer = text
break
if strip_think and answer:
answer = _THINK_TAG_RE.sub("", answer).strip()
return answer
_OUTPUTS_VIRTUAL_PREFIX = "/mnt/user-data/outputs"
_MAX_OUTPUT_FILES = 20
_MAX_OUTPUT_FILE_BYTES = 5 * 1024 * 1024 # 单文件 5MB 上限,避免把大二进制塞进 JSON 响应
def _collect_output_files(state: Any, *, include: bool) -> list[OpenChatFile]:
"""读取本次运行在沙箱 outputs 目录生成的文件,随结果一并回传。
开放问答是无状态的(不持久化线程),客户端没法用 artifacts 路由按线程取件;但本端点
就跑在服务端进程里,沙箱把生成的文件落在 ``state['thread_data']['outputs_path']``,
所以这里直接扫描该目录,把文件内容带回(文本按 utf-8,二进制按 base64)。
受 ``_MAX_OUTPUT_FILES`` / ``_MAX_OUTPUT_FILE_BYTES`` 限制。
"""
if not include or not isinstance(state, dict):
return []
thread_data = state.get("thread_data") or {}
outputs_path = thread_data.get("outputs_path")
if not outputs_path:
return []
base = Path(outputs_path)
if not base.is_dir():
return []
try:
entries = sorted((p for p in base.rglob("*") if p.is_file()), key=lambda p: p.as_posix())
except OSError:
return []
files: list[OpenChatFile] = []
for entry in entries:
if len(files) >= _MAX_OUTPUT_FILES:
break
try:
if entry.stat().st_size > _MAX_OUTPUT_FILE_BYTES:
continue
data = entry.read_bytes()
except OSError:
continue
rel = entry.relative_to(base).as_posix()
try:
content = data.decode("utf-8")
encoding: Literal["text", "base64"] = "text"
except UnicodeDecodeError:
content = base64.b64encode(data).decode("ascii")
encoding = "base64"
files.append(OpenChatFile(name=rel, virtual_path=f"{_OUTPUTS_VIRTUAL_PREFIX}/{rel}", encoding=encoding, content=content))
return files
def _cleanup_ephemeral_thread(state: Any, thread_id: str | None) -> None:
"""删除本次开放问答的临时线程目录(open-*),落实"聊天记录不存储"。
生成文件已在 _collect_output_files 读进内存(随响应回传),这里把磁盘上整个临时线程
目录清掉,避免无状态调用在服务端堆积残留。仅对 ``open-*`` 线程生效,best-effort。
"""
if not thread_id or not str(thread_id).startswith("open-"):
return
thread_data = state.get("thread_data") if isinstance(state, dict) else None
outputs_path = (thread_data or {}).get("outputs_path")
if not outputs_path:
return
try:
p = Path(outputs_path).resolve()
# outputs_path 形如 .../threads/<thread_id>/user-data/outputs;只删到 <thread_id> 这层
thread_root = next((parent for parent in p.parents if parent.name == thread_id), None)
if thread_root is not None and thread_root.exists():
shutil.rmtree(thread_root, ignore_errors=True)
except Exception as exc: # noqa: BLE001 — 清理失败不影响返回结果
logger.warning("清理临时线程目录失败 (%s): %s", thread_id, exc)
# 流式响应每隔这么久没有真实事件就发一个 SSE 注释心跳,持续制造下行字节,
# 让前置反向代理(nginx proxy_read_timeout 默认 60s 等)不会把长任务判定成空闲超时。
_HEARTBEAT_SECONDS = 15.0
def _build_run_context(body: OpenChatRequest) -> tuple[dict[str, Any], str | None, str]:
"""把请求体翻译成 (config, agent_id, thread_id)。两个端点共用,确保关思考等行为一致。"""
agent_id = _resolve_agent_id(body.agent_id, body.agent_name)
thread_id = body.thread_id or f"open-{uuid.uuid4()}"
configurable: dict[str, Any] = {
"thread_id": thread_id,
"model_name": body.model_name,
"thinking_enabled": body.thinking_enabled,
# 关闭思考时强制下发,确保内网模型也真的关掉(见模块 docstring)。
"thinking_force_disabled": not body.thinking_enabled,
# 一次性问答:跳过自动起标题(省一次 LLM 调用)、不挂定时任务工具。
"is_scheduled_run": True,
# 临时/无状态运行:跳过记忆与知识库的读写(不召回、不留存、不自动沉淀),
# 做到"通过本接口的聊天记录不存储"(见 _build_middlewares 的 is_ephemeral)。
"ephemeral": True,
"memory_injection_enabled": False,
}
if agent_id:
configurable["agent_id"] = agent_id
config: dict[str, Any] = {"configurable": configurable, "recursion_limit": body.recursion_limit}
return config, agent_id, thread_id
def _sse(event: str | None, data: Any) -> str:
"""序列化一条 SSE 消息。event=None 时只发 data 行。"""
payload = json.dumps(data, ensure_ascii=False)
head = f"event: {event}\n" if event else ""
return f"{head}data: {payload}\n\n"
@router.post("/chat", response_model=OpenChatResponse)
async def open_chat(body: OpenChatRequest) -> OpenChatResponse:
"""指定模型 + 智能体做一次问答(无状态、可关闭思考)。
同步阻塞版:整段调研跑完才返回。**长任务(联网调研)请改用 /chat/stream**——
本端点期间不产生任何下行字节,容易被前置反代的 proxy_read_timeout 掐成 504。
"""
lc_messages = _build_lc_messages(body)
config, agent_id, thread_id = _build_run_context(body)
try:
agent = make_lead_agent(config)
result = await agent.ainvoke({"messages": lc_messages}, config=config)
except Exception as exc: # noqa: BLE001 — 开放接口需把内部错误归一成 502
logger.exception("open_chat 调用智能体失败 (model=%s, agent=%s)", body.model_name, agent_id)
raise HTTPException(status_code=502, detail=f"智能体调用失败:{exc}") from exc
answer = _extract_answer(result, strip_think=body.strip_think and not body.thinking_enabled)
files = _collect_output_files(result, include=body.include_files)
_cleanup_ephemeral_thread(result, thread_id) # 取完文件即清理临时目录,不在服务端留存
if not answer and not files:
raise HTTPException(status_code=502, detail="智能体未返回任何文本答案或文件。")
return OpenChatResponse(
answer=answer,
model_name=body.model_name,
agent_name=body.agent_name,
agent_id=agent_id,
thinking_enabled=body.thinking_enabled,
thread_id=thread_id,
files=files,
)
async def _open_chat_event_stream(body: OpenChatRequest) -> AsyncIterator[str]:
"""驱动智能体并产出 SSE 事件流。
- 边跑边转发 AI 文本增量(``event: delta``,仅供实时观看,客户端可忽略);
- 没有真实事件时每 ``_HEARTBEAT_SECONDS`` 发一个心跳注释行保活;
- 全部跑完后用 ``event: result`` 给出最终(已按需剥离 <think> 的)答案;
- 出错则 ``event: error``。
用「后台生产任务 + 队列 + 超时取队列」的方式把「智能体进度」与「定时心跳」解耦,
所以即便某次工具调用静默很久,心跳仍照常下行,连接不会被反代判空闲。
"""
lc_messages = _build_lc_messages(body)
config, agent_id, thread_id = _build_run_context(body)
strip_think = body.strip_think and not body.thinking_enabled
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
final_state: dict[str, Any] = {}
async def produce() -> None:
nonlocal final_state
try:
agent = make_lead_agent(config)
async for mode, chunk in agent.astream({"messages": lc_messages}, config=config, stream_mode=["values", "messages"]):
if mode == "values" and isinstance(chunk, dict):
final_state = chunk
await queue.put(("progress", {"messages": len(chunk.get("messages") or [])}))
elif mode == "messages":
msg = chunk[0] if isinstance(chunk, tuple) and chunk else chunk
# 仅转发 AI 正文增量;DeepSeek 等把思考放在 additional_kwargs.reasoning_content,
# 不在 content 里,所以这里天然不会泄漏 <think>。
if isinstance(msg, AIMessage):
text = _extract_text(getattr(msg, "content", "")).strip()
if text:
await queue.put(("delta", {"text": text}))
except Exception as exc: # noqa: BLE001 — 开放接口需把内部错误归一成 error 事件
logger.exception("open_chat_stream 调用智能体失败 (model=%s, agent=%s)", body.model_name, agent_id)
await queue.put(("error", {"detail": f"智能体调用失败:{exc}"}))
finally:
await queue.put(("__end__", None))
task = asyncio.create_task(produce())
try:
while True:
try:
event, data = await asyncio.wait_for(queue.get(), timeout=_HEARTBEAT_SECONDS)
except asyncio.TimeoutError:
yield ": keepalive\n\n" # SSE 注释行,客户端忽略,仅用于保活
continue
if event == "__end__":
break
if event == "error":
yield _sse("error", data)
return
yield _sse(event, data)
answer = _extract_answer(final_state, strip_think=strip_think)
files = _collect_output_files(final_state, include=body.include_files)
_cleanup_ephemeral_thread(final_state, thread_id) # 取完文件即清理临时目录,不在服务端留存
if not answer and not files:
yield _sse("error", {"detail": "智能体未返回任何文本答案或文件。"})
return
yield _sse(
"result",
{
"answer": answer,
"model_name": body.model_name,
"agent_name": body.agent_name,
"agent_id": agent_id,
"thinking_enabled": body.thinking_enabled,
"thread_id": thread_id,
"files": [f.model_dump() for f in files],
},
)
finally:
if not task.done():
task.cancel()
@router.post("/chat/stream")
async def open_chat_stream(body: OpenChatRequest) -> StreamingResponse:
"""流式版 /chat:边跑边发心跳保活,跑完用 ``event: result`` 给最终答案。
解决两件事:(1) 长任务(联网调研、报告)同步阻塞会被前置反代 proxy_read_timeout
掐成 504;流式持续有字节下行,代理不再判定空闲超时。(2) 客户端只需读到
``event: result`` 解析 ``answer`` 即可,中间的 ``delta``/``progress``/心跳都可忽略。
"""
# 入参校验放在流外:非法请求直接抛 4xx,而不是开了流再报错。
_build_lc_messages(body)
return StreamingResponse(
_open_chat_event_stream(body),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
# 关掉 nginx 对该响应的缓冲,确保 SSE/心跳实时下行(即便前置 nginx 默认开缓冲)。
"X-Accel-Buffering": "no",
},
)
@router.get("/agents")
async def open_list_agents() -> dict[str, Any]:
"""列出可用智能体的名称(方便知道 agent_name 该填什么)。"""
agents = list_custom_agents()
items = [{"name": a.name, "id": a.id} for a in agents if (a.name or "").strip()]
return {"agents": items, "count": len(items)}
@@ -67,24 +67,11 @@ async def list_recommended_questions(
) -> RecommendedQuestionsListResponse:
store = _get_store(request)
rows = await store.list_all()
if not all:
# Filter by the caller's 岗位 (position) visibility. ``None`` =
# unrestricted (no skill/question tag binding → show all default
# questions); a set = show only the questions carrying the bound tags.
sync = getattr(request.app.state, "position_sync", None)
if sync is not None:
try:
visible = await sync.resolve_visible(_current_user_id(request), "recommended_question")
except Exception:
logger.debug("recommended-question visibility resolve failed", exc_info=True)
visible = None
if visible is not None:
rows = [r for r in rows if r["id"] in visible]
responses = [RecommendedQuestionResponse(**r) for r in rows]
# Bulk-attach the tags assigned to each question so the management list can
# display them and filter by 标签. Best-effort: a missing/failing tag store
# never breaks the question list.
# display them and the 岗位 filter below can tell tagged from untagged.
# Best-effort: a missing/failing tag store never breaks the question list.
tag_store = getattr(request.app.state, "tag_store", None)
if tag_store is not None and responses:
try:
@@ -95,6 +82,24 @@ async def list_recommended_questions(
for resp in responses:
resp.tags = assignments.get(resp.id, [])
if not all:
# Filter by the caller's 岗位 (position) visibility:
# * ``set`` — the position binds recommended_question 标签 → show only
# the questions carrying those bound tags.
# * ``None`` — 默认岗位 / 无标签绑定 → show only **untagged** questions.
# 打标的题目只在绑定了对应标签的岗位展示,不再泄漏到默认岗位。
sync = getattr(request.app.state, "position_sync", None)
if sync is not None:
try:
visible = await sync.resolve_visible(_current_user_id(request), "recommended_question")
except Exception:
logger.debug("recommended-question visibility resolve failed", exc_info=True)
visible = None
if visible is not None:
responses = [r for r in responses if r.id in visible]
else:
responses = [r for r in responses if not r.tags]
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
@@ -0,0 +1,713 @@
"""技能地址(URL/IP)批量替换 API (技能地址管理).
Scans the **running** skills directory (``skills/{public,custom}``) — the files
the agent actually reads — for ``http(s)://`` URLs and IPv4 (``ip[:port]``)
addresses, aggregates each unique address with every place it appears (across
``.md`` and ``.py``, across files), and lets an admin rewrite them in bulk.
Why this shape: the same address routinely lives in both a skill's ``SKILL.md``
(documented endpoint) and its ``scripts/*.py`` (the actual call), so the natural
unit is *"one address → replace all of its occurrences at once"*. The pure
scan/replace logic lives in ``deerflow.skills.address_scan`` (harness layer,
substring-safe span replacement); this router only walks the tree, enforces
admin auth + path safety, backs up before writing, and refreshes the skills
system-prompt cache so edits take effect immediately.
**Scale / availability (150+ skills):** every filesystem walk / read / write is
offloaded to a worker thread via ``asyncio.to_thread`` so the event loop is
never blocked while scanning or rewriting — the Gateway stays responsive. The
streaming endpoints additionally emit NDJSON progress (``已处理 X / 共 N``) so the
UI can render a progress bar for both the scan and the apply phases.
Routes (prefix ``/api/skill-addresses``, **admin only**):
GET /scan?kinds=url,ip&skill=<name> read-only aggregated scan (one shot)
GET /scan/stream?kinds=...&skill=... scan with per-skill NDJSON progress
POST /preview dry-run diff of a replacement set
POST /apply write changes (one shot, with backup)
POST /apply/stream write changes with per-file progress
GET /backups list restorable backups (newest first)
POST /rollback/{backup_id} restore a backup
"""
from __future__ import annotations
import asyncio
import json
import logging
import shutil
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from app.gateway.deps import get_optional_user_from_request
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.skills.address_scan import (
VALID_KINDS,
apply_replacements,
infer_kind,
is_valid_replacement,
normalize_kinds,
scan_occurrences,
)
from deerflow.skills.storage import get_or_new_skill_storage
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/skill-addresses", tags=["skill-addresses"])
_BACKUP_DIRNAME = ".address-edits"
_SCAN_SUBDIRS = ("public", "custom")
_SCAN_EXTS = (".md", ".py")
# ── schemas ────────────────────────────────────────────────────────────────
class OccurrenceModel(BaseModel):
skill: str
category: str # "public" | "custom"
file: str # POSIX path relative to the skills root
line: int
col: int
snippet: str
class AddressGroup(BaseModel):
address: str
kind: str # "url" | "ip"
count: int
occurrences: list[OccurrenceModel]
class ScanResponse(BaseModel):
groups: list[AddressGroup]
scanned_files: int
scanned_skills: int
kinds: list[str]
scanned_at: str
class Replacement(BaseModel):
model_config = ConfigDict(populate_by_name=True)
from_: str = Field(alias="from", min_length=1)
to: str = Field(min_length=1)
kind: str | None = None
class PreviewRequest(BaseModel):
replacements: list[Replacement] = Field(default_factory=list)
class PreviewFileHit(BaseModel):
file: str
line: int
before: str
after: str
class PreviewItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
from_: str = Field(serialization_alias="from")
to: str
kind: str
count: int
files: list[PreviewFileHit]
class PreviewResponse(BaseModel):
items: list[PreviewItem]
total: int
warnings: list[str]
expected_total: int
class ApplyRequest(BaseModel):
replacements: list[Replacement] = Field(default_factory=list)
expected_total: int | None = None
backup: bool = True
class ChangedFile(BaseModel):
file: str
count: int
class ApplyResponse(BaseModel):
changed_files: list[ChangedFile]
replaced_total: int
backup_id: str | None
warnings: list[str]
class ReplacementSummary(BaseModel):
model_config = ConfigDict(populate_by_name=True)
from_: str = Field(serialization_alias="from")
to: str
kind: str
class BackupEntry(BaseModel):
backup_id: str
files: int
created_at: str
replaced_total: int = 0
replacements: list[ReplacementSummary] = Field(default_factory=list)
class BackupListResponse(BaseModel):
backups: list[BackupEntry]
class RollbackResponse(BaseModel):
backup_id: str
restored_files: list[str]
# ── auth / path helpers ──────────────────────────────────────────────────────
async def _require_admin(request: Request) -> None:
"""Admin-only. Lets the call through when auth is disabled (no user)."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="技能地址管理仅限管理员")
def _skills_root() -> Path:
return Path(get_or_new_skill_storage().get_skills_root_path()).resolve()
def _is_within(root: Path, p: Path) -> bool:
try:
p.resolve().relative_to(root)
return True
except (ValueError, OSError):
return False
def _skill_of(root: Path, path: Path) -> tuple[str, str, str]:
"""Return ``(category, skill_name, posix_rel_path)`` for a scanned file."""
rel = path.relative_to(root)
parts = rel.parts
category = parts[0] if parts else ""
skill_name = parts[1] if len(parts) > 1 else ""
return category, skill_name, rel.as_posix()
def _read_text(path: Path) -> str | None:
"""Read UTF-8 text preserving original line endings; skip binary/bad files."""
try:
with open(path, "r", encoding="utf-8", newline="") as fh:
return fh.read()
except (UnicodeDecodeError, OSError):
return None
def _write_text(path: Path, text: str) -> None:
with open(path, "w", encoding="utf-8", newline="") as fh:
fh.write(text)
# ── synchronous workers (run via asyncio.to_thread; never block the loop) ─────
def _iter_skill_files(root: Path, skill: str | None = None) -> list[Path]:
"""All ``*.md`` / ``*.py`` under ``{root}/{public,custom}``.
Hidden directories (``.address-edits`` backups, ``.history``) are skipped.
When *skill* is given, only that skill's directory is walked.
"""
files: list[Path] = []
for sub in _SCAN_SUBDIRS:
base = root / sub
if skill:
base = base / skill
if not base.is_dir():
continue
for p in base.rglob("*"):
if not p.is_file() or p.suffix.lower() not in _SCAN_EXTS:
continue
try:
rel_parts = p.relative_to(root).parts
except ValueError:
continue
if any(part.startswith(".") for part in rel_parts):
continue
files.append(p)
return files
def _group_files_by_skill(root: Path, files: list[Path]) -> dict[tuple[str, str], list[Path]]:
"""Bucket scanned files under their owning ``(category, skill_name)``."""
buckets: dict[tuple[str, str], list[Path]] = {}
for p in files:
category, skill_name, _ = _skill_of(root, p)
buckets.setdefault((category, skill_name), []).append(p)
return buckets
def _occurrence_kind(value: str) -> str:
return infer_kind(value) or "url"
def _merge_occurrences(groups: dict[str, AddressGroup], pairs: list[tuple[str, OccurrenceModel]]) -> None:
"""Merge ``(address, occurrence)`` pairs into the aggregate group map."""
for address, occ in pairs:
g = groups.get(address)
if g is None:
g = AddressGroup(address=address, kind=_occurrence_kind(address), count=0, occurrences=[])
groups[address] = g
g.count += 1
g.occurrences.append(occ)
def _plan_apply(root: Path, mapping: dict[str, str]) -> tuple[list[tuple[Path, str, int, str]], int]:
"""Compute the rewrite plan without touching disk → (plan, current_total)."""
plan: list[tuple[Path, str, int, str]] = []
current_total = 0
for path in _iter_skill_files(root):
text = _read_text(path)
if text is None:
continue
new_text, cnt = apply_replacements(text, mapping, VALID_KINDS)
if cnt <= 0:
continue
if not _is_within(root, path):
raise HTTPException(status_code=400, detail=f"拒绝写入技能目录之外的文件:{path}")
_, _, rel = _skill_of(root, path)
plan.append((path, new_text, cnt, rel))
current_total += cnt
return plan, current_total
def _backup_file(root: Path, backup_id: str, path: Path, rel: str) -> None:
dest = root / _BACKUP_DIRNAME / backup_id / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, dest)
_MANIFEST_SUFFIX = ".manifest.json"
def _manifest_path(root: Path, backup_id: str) -> Path:
"""Manifest lives as a *sibling* of the backup dir (``{id}.manifest.json``),
not inside it — so it is neither restored back into the skills tree on
rollback nor counted as a backed-up file."""
return root / _BACKUP_DIRNAME / f"{backup_id}{_MANIFEST_SUFFIX}"
def _write_manifest(
root: Path,
backup_id: str,
norm: list[tuple[str, str, str]],
changed: list["ChangedFile"],
replaced_total: int,
created_at: str,
) -> None:
"""Record what this edit changed so the UI can show a meaningful, per-edit
history (原地址 → 新地址, 文件数, 处数) and roll a single record back."""
data = {
"created_at": created_at,
"replaced_total": replaced_total,
"replacements": [{"from": frm, "to": to, "kind": kind} for frm, to, kind in norm],
"changed_files": [{"file": c.file, "count": c.count} for c in changed],
}
try: # best-effort: a metadata write failure must not fail the apply itself
path = _manifest_path(root, backup_id)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False)
except OSError:
logger.warning("Failed to write skill-address backup manifest for %s", backup_id, exc_info=True)
def _read_manifest(root: Path, backup_id: str) -> dict | None:
try:
with open(_manifest_path(root, backup_id), "r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError):
return None
# ── scan extraction shared by one-shot + stream ───────────────────────────────
def _scan_files_with_addresses(root: Path, files: list[Path], wanted: tuple[str, ...]) -> tuple[list[tuple[str, OccurrenceModel]], int]:
"""Scan files → ([(address, occurrence)], files_read)."""
pairs: list[tuple[str, OccurrenceModel]] = []
read = 0
for path in files:
text = _read_text(path)
if text is None:
continue
read += 1
category, skill_name, rel = _skill_of(root, path)
for o in scan_occurrences(text, wanted):
pairs.append(
(
o.value,
OccurrenceModel(skill=skill_name, category=category, file=rel, line=o.line, col=o.col, snippet=o.snippet),
)
)
return pairs, read
def _ordered_groups(groups: dict[str, AddressGroup]) -> list[AddressGroup]:
return sorted(groups.values(), key=lambda g: (-g.count, g.address))
def _build_mapping(replacements: list[Replacement]) -> tuple[dict[str, str], list[tuple[str, str, str]], list[str]]:
"""Validate replacements → ``(mapping, [(from,to,kind)], warnings)``.
Drops (with a warning) any entry that is empty, duplicated, not a recognized
URL/IP, a no-op (``from == to``), or whose ``to`` is malformed for its kind.
"""
mapping: dict[str, str] = {}
norm: list[tuple[str, str, str]] = []
warnings: list[str] = []
seen: set[str] = set()
for r in replacements:
frm = r.from_.strip()
to = r.to.strip()
if not frm:
continue
if frm in seen:
warnings.append(f"重复的原始地址已忽略:{frm}")
continue
kind = (r.kind or "").strip().lower() or infer_kind(frm)
if kind not in VALID_KINDS:
warnings.append(f"无法识别为 URL/IP,已忽略:{frm}")
continue
if to == frm:
warnings.append(f"替换值与原值相同,已忽略:{frm}")
continue
if not is_valid_replacement(kind, to):
warnings.append(f"替换值不是合法的 {kind},已忽略:{to}")
continue
seen.add(frm)
mapping[frm] = to
norm.append((frm, to, kind))
return mapping, norm, warnings
def _ndjson(obj: dict) -> bytes:
return (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
# ── routes: scan ─────────────────────────────────────────────────────────────
@router.get("/scan", response_model=ScanResponse)
async def scan_addresses(
request: Request,
kinds: str | None = Query(default=None, description="逗号分隔:url,ip(默认全部)"),
skill: str | None = Query(default=None, description="仅扫描指定技能目录"),
) -> ScanResponse:
await _require_admin(request)
root = _skills_root()
wanted = normalize_kinds(kinds)
files = await asyncio.to_thread(_iter_skill_files, root, skill)
buckets = _group_files_by_skill(root, files)
groups: dict[str, AddressGroup] = {}
scanned = 0
for paths in buckets.values():
pairs, read = await asyncio.to_thread(_scan_files_with_addresses, root, paths, wanted)
_merge_occurrences(groups, pairs)
scanned += read
return ScanResponse(
groups=_ordered_groups(groups),
scanned_files=scanned,
scanned_skills=len(buckets),
kinds=list(wanted),
scanned_at=datetime.now(UTC).isoformat(),
)
@router.get("/scan/stream")
async def scan_addresses_stream(
request: Request,
kinds: str | None = Query(default=None),
skill: str | None = Query(default=None),
) -> StreamingResponse:
"""Scan with per-skill NDJSON progress.
Emits ``{"type":"start","total":N}`` then one
``{"type":"progress","done":i,"total":N,"skill":name}`` per skill, finally
``{"type":"result", ...ScanResponse...}``.
"""
await _require_admin(request)
root = _skills_root()
wanted = normalize_kinds(kinds)
async def gen() -> AsyncIterator[bytes]:
files = await asyncio.to_thread(_iter_skill_files, root, skill)
buckets = _group_files_by_skill(root, files)
total = len(buckets)
yield _ndjson({"type": "start", "total": total})
groups: dict[str, AddressGroup] = {}
scanned = 0
done = 0
for (category, skill_name), paths in buckets.items():
pairs, read = await asyncio.to_thread(_scan_files_with_addresses, root, paths, wanted)
_merge_occurrences(groups, pairs)
scanned += read
done += 1
yield _ndjson({"type": "progress", "done": done, "total": total, "skill": skill_name or category})
result = ScanResponse(
groups=_ordered_groups(groups),
scanned_files=scanned,
scanned_skills=total,
kinds=list(wanted),
scanned_at=datetime.now(UTC).isoformat(),
)
yield _ndjson({"type": "result", **result.model_dump()})
return StreamingResponse(gen(), media_type="application/x-ndjson")
# ── routes: preview ──────────────────────────────────────────────────────────
def _compute_preview(root: Path, mapping: dict[str, str], norm: list[tuple[str, str, str]]) -> tuple[list[PreviewItem], int]:
counts: dict[str, int] = {frm: 0 for frm, _, _ in norm}
files: dict[str, list[PreviewFileHit]] = {frm: [] for frm, _, _ in norm}
total = 0
if mapping:
for path in _iter_skill_files(root):
text = _read_text(path)
if text is None:
continue
_, _, rel = _skill_of(root, path)
for o in scan_occurrences(text, VALID_KINDS):
if o.value not in mapping:
continue
to = mapping[o.value]
counts[o.value] += 1
total += 1
files[o.value].append(PreviewFileHit(file=rel, line=o.line, before=o.snippet, after=o.snippet.replace(o.value, to)))
items = [PreviewItem(from_=frm, to=to, kind=kind, count=counts[frm], files=files[frm]) for frm, to, kind in norm]
return items, total
@router.post("/preview", response_model=PreviewResponse)
async def preview_replacements(request: Request, body: PreviewRequest) -> PreviewResponse:
await _require_admin(request)
root = _skills_root()
mapping, norm, warnings = _build_mapping(body.replacements)
items, total = await asyncio.to_thread(_compute_preview, root, mapping, norm)
return PreviewResponse(items=items, total=total, warnings=warnings, expected_total=total)
# ── routes: apply ────────────────────────────────────────────────────────────
def _new_backup_id() -> str:
return datetime.now(UTC).strftime("%Y%m%d-%H%M%S-%f")
@router.post("/apply", response_model=ApplyResponse)
async def apply_replacements_route(request: Request, body: ApplyRequest) -> ApplyResponse:
await _require_admin(request)
root = _skills_root()
mapping, norm, warnings = _build_mapping(body.replacements)
if not mapping:
return ApplyResponse(changed_files=[], replaced_total=0, backup_id=None, warnings=warnings)
plan, current_total = await asyncio.to_thread(_plan_apply, root, mapping)
if body.expected_total is not None and body.expected_total != current_total:
raise HTTPException(
status_code=409,
detail=f"文件自上次扫描后已变化(预期 {body.expected_total} 处,现为 {current_total} 处),请重新扫描后再应用。",
)
if not plan:
return ApplyResponse(changed_files=[], replaced_total=0, backup_id=None, warnings=warnings)
backup_id: str | None = None
if body.backup:
backup_id = _new_backup_id()
for path, _new, _cnt, rel in plan:
await asyncio.to_thread(_backup_file, root, backup_id, path, rel)
changed: list[ChangedFile] = []
replaced_total = 0
for path, new_text, cnt, rel in plan:
await asyncio.to_thread(_write_text, path, new_text)
changed.append(ChangedFile(file=rel, count=cnt))
replaced_total += cnt
if backup_id:
await asyncio.to_thread(_write_manifest, root, backup_id, norm, changed, replaced_total, datetime.now(UTC).isoformat())
await _refresh_cache_safe()
logger.info("Skill address apply: %d files, %d replacements, backup=%s", len(changed), replaced_total, backup_id)
return ApplyResponse(changed_files=changed, replaced_total=replaced_total, backup_id=backup_id, warnings=warnings)
@router.post("/apply/stream")
async def apply_replacements_stream(request: Request, body: ApplyRequest) -> StreamingResponse:
"""Apply with per-file NDJSON progress.
Validation + the optimistic-lock check happen *before* streaming (so a 409
is a normal HTTP error). Then: ``{"type":"start","total_files":M,...}`` →
one ``{"type":"progress","done":k,...}`` per written file →
``{"type":"result", ...ApplyResponse...}``.
"""
await _require_admin(request)
root = _skills_root()
mapping, norm, warnings = _build_mapping(body.replacements)
plan: list[tuple[Path, str, int, str]] = []
current_total = 0
if mapping:
plan, current_total = await asyncio.to_thread(_plan_apply, root, mapping)
if body.expected_total is not None and body.expected_total != current_total:
raise HTTPException(
status_code=409,
detail=f"文件自上次扫描后已变化(预期 {body.expected_total} 处,现为 {current_total} 处),请重新扫描后再应用。",
)
async def gen() -> AsyncIterator[bytes]:
if not plan:
result = ApplyResponse(changed_files=[], replaced_total=0, backup_id=None, warnings=warnings)
yield _ndjson({"type": "result", **result.model_dump()})
return
backup_id: str | None = None
if body.backup:
backup_id = _new_backup_id()
yield _ndjson({"type": "start", "total_files": len(plan), "total_replacements": current_total})
changed: list[ChangedFile] = []
replaced_total = 0
done = 0
for path, new_text, cnt, rel in plan:
if backup_id:
await asyncio.to_thread(_backup_file, root, backup_id, path, rel)
await asyncio.to_thread(_write_text, path, new_text)
changed.append(ChangedFile(file=rel, count=cnt))
replaced_total += cnt
done += 1
yield _ndjson({"type": "progress", "done": done, "total_files": len(plan), "file": rel, "count": cnt})
if backup_id:
await asyncio.to_thread(_write_manifest, root, backup_id, norm, changed, replaced_total, datetime.now(UTC).isoformat())
await _refresh_cache_safe()
logger.info("Skill address apply(stream): %d files, %d replacements, backup=%s", len(changed), replaced_total, backup_id)
result = ApplyResponse(changed_files=changed, replaced_total=replaced_total, backup_id=backup_id, warnings=warnings)
yield _ndjson({"type": "result", **result.model_dump()})
return StreamingResponse(gen(), media_type="application/x-ndjson")
# ── routes: backups / rollback ───────────────────────────────────────────────
def _list_backups(root: Path) -> list[BackupEntry]:
backup_root = root / _BACKUP_DIRNAME
entries: list[BackupEntry] = []
if backup_root.is_dir():
for d in backup_root.iterdir():
if not d.is_dir():
continue
file_count = sum(1 for p in d.rglob("*") if p.is_file())
manifest = _read_manifest(root, d.name)
created = ""
replaced_total = 0
replacements: list[ReplacementSummary] = []
if manifest:
created = str(manifest.get("created_at") or "")
replaced_total = int(manifest.get("replaced_total") or 0)
replacements = [
ReplacementSummary(from_=str(r.get("from", "")), to=str(r.get("to", "")), kind=str(r.get("kind", "")))
for r in manifest.get("replacements", [])
if isinstance(r, dict)
]
if not created:
try:
created = datetime.fromtimestamp(d.stat().st_mtime, UTC).isoformat()
except OSError:
created = ""
entries.append(
BackupEntry(
backup_id=d.name,
files=file_count,
created_at=created,
replaced_total=replaced_total,
replacements=replacements,
)
)
entries.sort(key=lambda e: e.backup_id, reverse=True)
return entries
@router.get("/backups", response_model=BackupListResponse)
async def list_backups(request: Request) -> BackupListResponse:
await _require_admin(request)
root = _skills_root()
entries = await asyncio.to_thread(_list_backups, root)
return BackupListResponse(backups=entries)
def _restore_backup(root: Path, backup_root: Path) -> list[str]:
restored: list[str] = []
for src in backup_root.rglob("*"):
if not src.is_file():
continue
rel = src.relative_to(backup_root)
dest = root / rel
if not _is_within(root, dest):
continue
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
restored.append(rel.as_posix())
return restored
@router.post("/rollback/{backup_id}", response_model=RollbackResponse)
async def rollback(request: Request, backup_id: str) -> RollbackResponse:
await _require_admin(request)
root = _skills_root()
if "/" in backup_id or "\\" in backup_id or backup_id in (".", ".."):
raise HTTPException(status_code=400, detail="非法的备份编号")
backup_root = (root / _BACKUP_DIRNAME / backup_id).resolve()
if not _is_within(root, backup_root) or not backup_root.is_dir():
raise HTTPException(status_code=404, detail="备份不存在")
restored = await asyncio.to_thread(_restore_backup, root, backup_root)
await _refresh_cache_safe()
logger.info("Skill address rollback: backup=%s, restored %d files", backup_id, len(restored))
return RollbackResponse(backup_id=backup_id, restored_files=restored)
# ── misc ─────────────────────────────────────────────────────────────────────
async def _refresh_cache_safe() -> None:
try:
await refresh_skills_system_prompt_cache_async()
except Exception: # noqa: BLE001 — cache refresh is best-effort
logger.warning("Failed to refresh skills prompt cache", exc_info=True)
@@ -222,6 +222,10 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"model_name",
"mode",
"thinking_enabled",
# 强制关闭思考(内网 vLLM/Qwen 模型只声明 supports_thinking 时,单靠
# thinking_enabled=False 不会注入 enable_thinking=false)。透传到 run config
# 后由 lead agent 交给 create_chat_model(force_disable_thinking=...)。
"thinking_force_disabled",
"reasoning_effort",
"rag_mode_enabled",
"writing_mode",
@@ -123,6 +123,12 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
# SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」,
# 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后,
# 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。
when_thinking_disabled:
extra_body:
enable_thinking: false
- name: Pro/MiniMaxAI/MiniMax-M2.5
display_name: Pro/MiniMaxAI/MiniMax-M2.5
use: langchain_openai:ChatOpenAI
@@ -306,17 +306,25 @@ def _build_middlewares(
if not cfg.get("is_scheduled_run", False):
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
# Ephemeral runs (e.g. the open Q&A API /api/open/chat) must NOT touch any
# server-side per-user state: no memory recall/retain, no knowledge-base
# recall/auto-ingest. Those runs use the shared "default" user bucket, so
# persisting their conversations there would both store records the caller
# explicitly asked NOT to keep and leak content across unrelated callers.
# Skip both write-capable middlewares entirely.
is_ephemeral = cfg.get("ephemeral", False)
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap and not is_writing_setup:
if not is_bootstrap and not is_writing_setup and not is_ephemeral:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated
# per-conversation by the ``knowledge_rag_enabled`` runtime flag, defaulting
# to the global ``knowledge.rag_enabled`` config. No-op when knowledge is
# disabled. Skipped in bootstrap/writing-setup like MemoryMiddleware.
# disabled. Skipped in bootstrap/writing-setup/ephemeral like MemoryMiddleware.
knowledge_cfg = getattr(resolved_app_config, "knowledge", None)
if not is_bootstrap and not is_writing_setup and knowledge_cfg is not None and knowledge_cfg.enabled:
if not is_bootstrap and not is_writing_setup and not is_ephemeral and knowledge_cfg is not None and knowledge_cfg.enabled:
from deerflow.agents.middlewares.knowledge_rag_middleware import KnowledgeRagMiddleware
middlewares.append(KnowledgeRagMiddleware(limit=knowledge_cfg.rag_limit, default_enabled=knowledge_cfg.rag_enabled))
@@ -412,6 +420,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
resolved_app_config = app_config
thinking_enabled = cfg.get("thinking_enabled", True)
# Hard "thinking off" switch for internal vLLM/Qwen models whose config only
# declares `supports_thinking: true` (no disable shape) — passes through to
# create_chat_model(force_disable_thinking=...) so enable_thinking=false is
# actually injected. Default off → no behavior change for normal chats.
thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False))
reasoning_effort = cfg.get("reasoning_effort", None)
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
is_plan_mode = cfg.get("is_plan_mode", False)
@@ -501,7 +514,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=[setup_writing, ask_clarification_tool],
middleware=[
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
@@ -514,7 +527,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if is_bootstrap:
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=get_available_tools(
model_name=model_name,
subagent_enabled=subagent_enabled,
@@ -593,7 +606,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
)
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=default_tools,
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
system_prompt=system_prompt,
@@ -5,7 +5,8 @@
随时改 ``config.yaml`` 即可(Gateway 有 mtime 热重载,无需重新构建前端)。
测试开关(线上接口未通时用假数据,互相独立):
- ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据。
- ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据;
同时 rwfx 进入「3q 是否完成」预检(``selectSqReport``,外部接口不可用)走 ``mock.sq_report`` 假数据。
- ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。
- ``action_test`` → 行动详情接口(``action_detail_url``)走 ``mock.action_detail`` 假数据。
xdfx 深链传入的是「行动id」,需先调该接口拿 ``data.taskId`` 解析出真正的「任务id」。
@@ -25,7 +26,7 @@ class TaskDeeplinkConfig(BaseModel):
test: bool = Field(
default=False,
description="为真时前端目的树查询走 mock.purpose_detail 假数据(不打真实接口)",
description="为真时前端目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检(selectSqReport)走 mock.sq_report 假数据(都不打真实接口)",
)
delete_test: bool = Field(
default=False,
@@ -61,5 +62,5 @@ class TaskDeeplinkConfig(BaseModel):
)
mock: dict[str, Any] = Field(
default_factory=dict,
description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}}",
description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}, sq_report: {state, msg, data: [...]}}(sq_report 供 rwfx 3q 预检,test 为真时用)",
)
@@ -47,11 +47,22 @@ def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_con
model_settings_from_config["stream_usage"] = True
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, **kwargs) -> BaseChatModel:
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, force_disable_thinking: bool = False, **kwargs) -> BaseChatModel:
"""Create a chat model instance from the config.
Args:
name: The name of the model to create. If None, the first model in the config will be used.
thinking_enabled: Turn the model's extended thinking / reasoning on.
force_disable_thinking: Hard override that disables thinking even when the
per-model config declares no ``when_thinking_*`` shape. Many internal
vLLM / Qwen models only set ``supports_thinking: true`` (no disable
shape), so passing ``thinking_enabled=False`` alone injects nothing and
the model keeps reasoning on. When this flag is set (and thinking is
off) we additionally inject both common OpenAI-compatible disable shapes
— vLLM/Qwen ``chat_template_kwargs.enable_thinking=False`` and Zhipu GLM
``thinking.type="disabled"`` — mirroring
``TitleMiddleware._force_disable_thinking``. No effect when thinking is
enabled, and skipped for model classes that don't accept ``extra_body``.
Returns:
A chat model instance.
@@ -120,6 +131,21 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
kwargs.pop("reasoning_effort", None)
model_settings_from_config.pop("reasoning_effort", None)
# Hard override: force-disable thinking regardless of the per-model config shape.
# Injected only for OpenAI-compatible classes (those declaring `extra_body`);
# other providers would reject the kwarg, so they are skipped (they ignore it).
# Three disable shapes are injected so the common OpenAI-compatible gateways are
# all covered: SiliconFlow / DashScope-style **top-level** ``enable_thinking``
# (DeepSeek-V3.1+/Qwen3/GLM hybrid models on api.siliconflow.cn — the knob the
# nested ``chat_template_kwargs`` shape does NOT reach), vLLM/Qwen native
# ``chat_template_kwargs.enable_thinking``, and Zhipu GLM ``thinking.type``.
# Unknown fields are ignored by these gateways, so sending all three is safe.
if force_disable_thinking and not thinking_enabled and "extra_body" in getattr(model_class, "model_fields", {}):
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
{"enable_thinking": False, "chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}},
)
_enable_stream_usage_by_default(model_config.use, model_settings_from_config)
# For Codex Responses API models: map thinking mode to reasoning_effort
@@ -16,8 +16,11 @@ Resolution rule (per resource type):
- ``scheduled_task`` → empty (no built-in tasks)
- ``skill`` → empty (the 广场 still shows every visible skill;
a position only *adds* its tagged skills to the member's "我的技能")
- ``recommended_question`` → *unrestricted* (handled at query time, never
materialized — see :meth:`resolve_visible`)
- ``recommended_question`` → *untagged-only* (handled at query time, never
materialized — see :meth:`resolve_visible`). The default set is the
questions carrying **no** 标签; tagged questions surface only for a position
that binds their tag. (The recommended-questions router interprets a
``None`` from :meth:`resolve_visible` as "show only untagged".)
Materialization (``sync_user``) only ever touches rows it created itself
(``origin='position'``), so a user's own favorites / subscriptions are never
@@ -67,12 +70,14 @@ class PositionSyncService:
return await self._positions.get_default_position_id()
async def resolve_visible(self, user_id: str, resource_type: str) -> set[str] | None:
"""Return the set of visible ``resource_type`` ids, or ``None`` = unrestricted.
"""Return the set of visible ``resource_type`` ids, or ``None`` = no tag binding.
Used by the recommended-questions filter (the only remaining query-time
visibility type; skills are now *materialized*, not filtered). ``None``
means "no position restriction → show everything"; a set means "restrict
to exactly these ids".
visibility type; skills are now *materialized*, not filtered). A set
means "restrict to exactly these ids". ``None`` means the effective
position binds no tag for this type — the recommended-questions router
treats that as "默认岗位 → show only untagged questions" (see
``app/gateway/routers/recommended_questions.py``).
"""
pid = await self._effective_position_id(user_id)
if not pid:
@@ -0,0 +1,255 @@
"""Scan skill text for URL / IP addresses and apply safe batch replacements.
Pure-logic layer for the 技能地址(URL/IP)批量替换 feature. This module knows
**only about text** — it never walks the filesystem. The HTTP router
(``app/gateway/routers/skill_addresses.py``) walks ``skills/{public,custom}``,
reads each ``*.md`` / ``*.py`` file, and feeds the text here. Keeping the
extraction / replacement logic here (no ``app.*`` import) makes it unit-testable
in isolation and respects the harness/app boundary.
Two address kinds are supported (the feature scope):
* ``"url"`` — full ``http(s)://…`` URLs.
* ``"ip"`` — dotted-quad IPv4, optionally ``:port``. Each octet is range
checked (0–255) so version-number noise like ``1.2.3.400`` is not matched.
The single most important invariant is **substring-safe replacement**: we never
``str.replace`` raw file text (that would rewrite ``1.2.3.4`` *inside*
``11.2.3.40``). Instead we re-detect the exact token spans and only rewrite a
span whose text is *exactly* one of the requested ``from`` values.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
__all__ = [
"VALID_KINDS",
"AddressSpan",
"Occurrence",
"normalize_kinds",
"find_spans",
"scan_occurrences",
"count_matches",
"apply_replacements",
"is_valid_replacement",
"infer_kind",
]
# Supported address kinds (the configured feature scope: URL + IP).
VALID_KINDS: tuple[str, ...] = ("url", "ip")
# A URL body stops at whitespace, quotes, brackets, angle brackets, backticks
# and backslashes — everything that commonly *delimits* a URL in md / py. The
# trailing punctuation that often clings to the end (a sentence period, a
# closing paren already excluded, a Markdown comma) is stripped afterwards.
_URL_RE = re.compile(r"https?://[^\s'\"`<>)\]}\\]+", re.IGNORECASE)
# Dotted-quad IPv4 (optionally :port). Guarded so it is not glued to a longer
# number run (``(?<![\d.])`` / ``(?![\d.])``); octets are range-checked below.
_IP_RE = re.compile(r"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){3})(?::(\d{1,5}))?(?![\d.])")
# Punctuation (ASCII + CJK) that should not be considered part of a URL token
# when it appears at the very end of the match.
_URL_TRAILING = set(".,;:!?'\")]}>`" + ")。,;:!?】》、…")
@dataclass(frozen=True)
class AddressSpan:
"""An address token located at an exact ``[start, end)`` offset in text."""
kind: str
value: str
start: int
end: int
@dataclass(frozen=True)
class Occurrence:
"""A located address enriched with 1-based line/column + the source line."""
kind: str
value: str
line: int
col: int
snippet: str
def normalize_kinds(kinds: object) -> tuple[str, ...]:
"""Coerce arbitrary input into an ordered, de-duped subset of ``VALID_KINDS``.
``None`` / empty means "all kinds". Unknown kinds are dropped.
"""
if kinds is None:
return VALID_KINDS
if isinstance(kinds, str):
raw = [p.strip() for p in kinds.split(",")]
else:
try:
raw = [str(p).strip() for p in kinds] # type: ignore[assignment]
except TypeError:
return VALID_KINDS
seen: list[str] = []
for k in raw:
kl = k.lower()
if kl in VALID_KINDS and kl not in seen:
seen.append(kl)
return tuple(seen) if seen else VALID_KINDS
def _valid_ipv4(quad: str) -> bool:
parts = quad.split(".")
if len(parts) != 4:
return False
for p in parts:
if not p.isdigit() or len(p) > 3:
return False
if int(p) > 255:
return False
return True
def _valid_port(port: str | None) -> bool:
if port is None:
return True
return port.isdigit() and 0 <= int(port) <= 65535
def _url_spans(text: str) -> list[AddressSpan]:
spans: list[AddressSpan] = []
for m in _URL_RE.finditer(text):
start, end = m.start(), m.end()
value = m.group(0)
# Strip clinging trailing punctuation.
while value and value[-1] in _URL_TRAILING:
value = value[:-1]
end -= 1
if value:
spans.append(AddressSpan(kind="url", value=value, start=start, end=end))
return spans
def _ip_spans(text: str, exclude: list[AddressSpan]) -> list[AddressSpan]:
spans: list[AddressSpan] = []
for m in _IP_RE.finditer(text):
quad = m.group(1)
port = m.group(2)
if not _valid_ipv4(quad) or not _valid_port(port):
continue
start, end = m.start(), m.end()
# Skip an IP that lives *inside* a URL (e.g. http://1.2.3.4:8080/x) —
# replacing just the IP there would corrupt the URL.
if any(s.start <= start < s.end for s in exclude):
continue
spans.append(AddressSpan(kind="ip", value=m.group(0), start=start, end=end))
return spans
def find_spans(text: str, kinds: object = None) -> list[AddressSpan]:
"""Return every address token span in *text*, ordered by position.
URL regions are always computed first so that IPs embedded inside a URL are
not separately surfaced, even when only ``"ip"`` is requested.
"""
wanted = normalize_kinds(kinds)
url_spans = _url_spans(text)
result: list[AddressSpan] = []
if "url" in wanted:
result.extend(url_spans)
if "ip" in wanted:
result.extend(_ip_spans(text, exclude=url_spans))
result.sort(key=lambda s: s.start)
return result
def scan_occurrences(text: str, kinds: object = None, *, snippet_max: int = 240) -> list[Occurrence]:
"""Locate addresses with 1-based line/column and a trimmed source line."""
spans = find_spans(text, kinds)
if not spans:
return []
# Precompute line-start offsets for O(log n) line lookup.
line_starts = [0]
for i, ch in enumerate(text):
if ch == "\n":
line_starts.append(i + 1)
occ: list[Occurrence] = []
import bisect
for s in spans:
line_idx = bisect.bisect_right(line_starts, s.start) - 1
line_no = line_idx + 1
col = s.start - line_starts[line_idx] + 1
line_end = line_starts[line_idx + 1] - 1 if line_idx + 1 < len(line_starts) else len(text)
raw_line = text[line_starts[line_idx]:line_end].rstrip("\r")
snippet = raw_line.strip()
if len(snippet) > snippet_max:
snippet = snippet[: snippet_max - 1] + "…"
occ.append(Occurrence(kind=s.kind, value=s.value, line=line_no, col=col, snippet=snippet))
return occ
def count_matches(text: str, mapping: dict[str, str], kinds: object = None) -> int:
"""How many address spans in *text* would be rewritten by *mapping*."""
if not mapping:
return 0
return sum(1 for s in find_spans(text, kinds) if s.value in mapping)
def apply_replacements(text: str, mapping: dict[str, str], kinds: object = None) -> tuple[str, int]:
"""Substring-safe batch replace.
Only spans whose text is *exactly* a key of *mapping* are rewritten, applied
right-to-left so earlier offsets stay valid. Returns ``(new_text, count)``.
A no-op mapping (or no hits) returns the original text and ``0``.
"""
if not mapping:
return text, 0
spans = [s for s in find_spans(text, kinds) if s.value in mapping]
if not spans:
return text, 0
out = text
for s in sorted(spans, key=lambda s: s.start, reverse=True):
out = out[: s.start] + mapping[s.value] + out[s.end:]
return out, len(spans)
def infer_kind(value: str) -> str | None:
"""Return the address kind of *value* if it is a single, whole address.
Used to classify a user-supplied ``from`` value (so the matching ``to`` can
be validated against the right rules). Returns ``None`` when *value* is not
exactly one URL or IP token.
"""
if not value:
return None
spans = find_spans(value, VALID_KINDS)
if len(spans) == 1 and spans[0].value == value:
return spans[0].kind
return None
def is_valid_replacement(kind: str, value: str) -> bool:
"""Validate that a user-supplied replacement is well-formed for its kind.
Keeps an admin from turning a URL into garbage (e.g. dropping the scheme) or
an IP into a non-IP. Empty replacements are rejected.
"""
if not value or value.strip() != value or any(c.isspace() for c in value):
return False
if kind == "url":
return bool(re.fullmatch(r"https?://[^\s'\"`<>]+", value, re.IGNORECASE))
if kind == "ip":
m = _IP_RE.fullmatch(value)
if not m:
return False
return _valid_ipv4(m.group(1)) and _valid_port(m.group(2))
return False
@@ -77,3 +77,65 @@ async def test_list_without_tag_store_does_not_break() -> None:
assert [q.id for q in resp.questions] == ["q1", "q2"]
assert all(q.tags == [] for q in resp.questions)
class _FakeSync:
"""Stub position-sync whose ``resolve_visible`` returns a fixed value.
``None`` mimics 默认岗位 / 无标签绑定; a set mimics a position that binds
recommended_question 标签.
"""
def __init__(self, visible: set[str] | None) -> None:
self._visible = visible
async def resolve_visible(self, _user_id, _resource_type): # noqa: ANN001
return self._visible
@pytest.mark.asyncio
async def test_default_position_shows_only_untagged() -> None:
"""默认岗位(resolve_visible → None)只看到未打标的题目。"""
tag_store = MemoryTagStore()
tag = await tag_store.create_tag(
{"id": "t1", "name": "理赔", "tag_type": "recommended_question"}
)
await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]])
req = _request(_FakeQuestionStore(_rows()), tag_store)
req.app.state.position_sync = _FakeSync(None)
resp = await rq_router.list_recommended_questions(req, all=False)
assert [q.id for q in resp.questions] == ["q1"]
@pytest.mark.asyncio
async def test_bound_position_shows_only_its_tagged_questions() -> None:
"""绑定了标签的岗位(resolve_visible → set)只看到对应打标的题目。"""
tag_store = MemoryTagStore()
tag = await tag_store.create_tag(
{"id": "t1", "name": "理赔", "tag_type": "recommended_question"}
)
await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]])
req = _request(_FakeQuestionStore(_rows()), tag_store)
req.app.state.position_sync = _FakeSync({"q2"})
resp = await rq_router.list_recommended_questions(req, all=False)
assert [q.id for q in resp.questions] == ["q2"]
@pytest.mark.asyncio
async def test_admin_view_ignores_position_filter() -> None:
"""``all=True`` 管理视图照旧返回全部(含打标),不受岗位过滤影响。"""
tag_store = MemoryTagStore()
tag = await tag_store.create_tag(
{"id": "t1", "name": "理赔", "tag_type": "recommended_question"}
)
await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]])
req = _request(_FakeQuestionStore(_rows()), tag_store)
req.app.state.position_sync = _FakeSync(None)
resp = await rq_router.list_recommended_questions(req, all=True)
assert [q.id for q in resp.questions] == ["q1", "q2"]
+19 -1
View File
@@ -77,6 +77,12 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
# SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」,
# 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后,
# 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。
when_thinking_disabled:
extra_body:
enable_thinking: false
- name: Pro/MiniMaxAI/MiniMax-M2.5
display_name: Pro/MiniMaxAI/MiniMax-M2.5
use: langchain_openai:ChatOpenAI
@@ -331,7 +337,7 @@ summarization:
# ?taskId= / ?id=),随时改这里即可(Gateway mtime 热重载,前端无需重建)。
task_deeplink:
# 线上接口未通时用假数据;三个开关互相独立。
test: true # 目的树查询走 mock.purpose_detail 假数据
test: true # 目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检走 mock.sq_report 假数据
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
@@ -382,3 +388,15 @@ task_deeplink:
actionName: ""
actionStatus: 25
narration: ""
# test=true 时 rwfx 进入「3q 是否完成」预检(selectSqReport,外部不可用)走这份假数据。
# 结构同真实 GET /consumer/cop/selectSqReport?taskId= 返回;前端判定 data 为空 / 每条
# contentJson 都是 null/空 = 「3q 未完成」→ 弹框引导去 3qfx;否则视为已完成(正常,不弹框)。
# 默认给一条有内容的数据 = 已完成(不打扰)。要测「3q 未完成」弹框:把下面 data 改成 []。
sq_report:
state: "200"
msg: "操作成功!"
data:
- id: "sq-mock-1"
taskId: 3338
categoryType: "敌情关键事件 JSON"
contentJson: "[{\"topic\":\"示例研判事件\",\"count\":1}]"