feat: 新增技能地址管理与开放问答接口,完善任务工作区

- 新增「技能地址管理」页面与 /api/skill-addresses 路由(扫描/预览/批量替换技能内地址)
- 完善 /api/open/chat 开放问答接口与模型工厂、网关配置
- 调整任务工作区、侧边栏与路由
- 将本地 0622/ 临时目录与 __pycache__/ 加入忽略,不提交

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 17:17:54 +08:00
1 parent 32476c7ad4
commit 6b480fdbe9
15 files changed
+1749 -40

No files matched your search

+6
View File
@@ -104,3 +104,9 @@ docs/backups
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
# Local-only scratch directory (not committed)
0622/
# Python bytecode cache
__pycache__/
+1
View File
@@ -82,6 +82,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。页面 `pages/SkillAddressesPage.tsx`(route `/page/strategy/admin/skill-addresses`,入口「技能地址管理」在 `components/page-sidebar.tsx` 左下角设置下拉,full+collapsed 两种模式):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**撤销上次**(回滚)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
@@ -9,6 +9,7 @@ import {
FolderOpen,
ListTree,
MousePointerClick,
Replace,
Settings,
ShieldAlert,
User,
@@ -222,6 +223,14 @@ function SecondaryNavSidebar() {
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/skill-addresses")}
>
<Replace className="size-4" />
技能地址管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
@@ -508,6 +517,12 @@ function LegacyAccordionSidebar({
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/skill-addresses")}>
<Replace className="size-4" />
技能地址管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
<ShieldAlert className="size-4" />
@@ -0,0 +1,522 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Eye,
FileCode,
Globe,
Info,
Loader2,
Network,
Replace,
ScanLine,
Search,
Undo2,
Wand2,
} from "lucide-react";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import {
type AddressGroup,
type AddressKind,
type ApplyProgress,
type PreviewResult,
type Replacement,
type ScanProgress,
type ScanResult,
applyReplacementsStream,
previewReplacements,
rollbackBackup,
scanAddressesStream,
useSkillAddressBackups,
} from "@/strategy-components/api/skill-addresses";
const ALL_KINDS: AddressKind[] = ["url", "ip"];
function ProgressBar({ done, total, label }: { done: number; total: number; label: string }) {
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
return (
<div className="rounded-xl border border-blue-200/70 bg-blue-50/60 p-3.5">
<div className="mb-1.5 flex items-center justify-between text-xs font-medium text-blue-700">
<span className="flex items-center gap-1.5">
<Loader2 className="size-3.5 animate-spin" />
{label}
</span>
<span className="font-mono">
{done} / {total}({pct}%)
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-blue-100">
<div className="h-full rounded-full bg-blue-500 transition-all duration-200" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
function KindBadge({ kind }: { kind: AddressKind }) {
const isUrl = kind === "url";
return (
<span
className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold ${
isUrl ? "bg-indigo-50 text-indigo-600" : "bg-emerald-50 text-emerald-600"
}`}
>
{isUrl ? <Globe className="size-3" /> : <Network className="size-3" />}
{isUrl ? "URL" : "IP"}
</span>
);
}
export default function SkillAddressesPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [kinds, setKinds] = useState<AddressKind[]>(ALL_KINDS);
const [scan, setScan] = useState<ScanResult | null>(null);
const [scanning, setScanning] = useState(false);
const [scanProg, setScanProg] = useState<ScanProgress | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({});
const [query, setQuery] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [applying, setApplying] = useState(false);
const [applyProg, setApplyProg] = useState<ApplyProgress | null>(null);
const [lastBackupId, setLastBackupId] = useState<string | null>(null);
const [segFind, setSegFind] = useState("");
const [segRepl, setSegRepl] = useState("");
const { data: backups = [], refetch: refetchBackups } = useSkillAddressBackups();
const runScan = useCallback(
async (useKinds: AddressKind[]) => {
setScanning(true);
setScanProg({ done: 0, total: 0 });
setPreview(null);
try {
const result = await scanAddressesStream({ kinds: useKinds, onProgress: setScanProg });
setScan(result);
} catch (err) {
toast.error(err instanceof Error ? err.message : "扫描失败");
} finally {
setScanning(false);
setScanProg(null);
}
},
[],
);
// 首次进入自动扫描。
useEffect(() => {
if (isAdmin) void runScan(ALL_KINDS);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin]);
const groups = scan?.groups ?? [];
const toggleKind = (k: AddressKind) => {
const next = kinds.includes(k) ? kinds.filter((x) => x !== k) : [...kinds, k];
if (next.length === 0) return; // 至少保留一种
setKinds(next);
void runScan(next);
};
const setEdit = (addr: string, val: string) => setEdits((p) => ({ ...p, [addr]: val }));
const toggleExpand = (addr: string) =>
setExpanded((p) => {
const n = new Set(p);
if (n.has(addr)) n.delete(addr);
else n.add(addr);
return n;
});
/** 片段批量套用:把所有地址里出现的「查找片段」整体替换为「替换片段」,填入各行的「替换为」。 */
const applySegment = () => {
const find = segFind.trim();
if (!find) {
toast.error("请先填写要查找的片段(如 uat.4.cn)");
return;
}
let n = 0;
setEdits((prev) => {
const next = { ...prev };
for (const g of groups) {
if (g.address.includes(find)) {
const replaced = g.address.split(find).join(segRepl);
if (replaced !== g.address) {
next[g.address] = replaced;
n++;
}
}
}
return next;
});
if (n === 0) toast.info(`没有地址包含片段「${find}」`);
else toast.success(`已为 ${n} 个地址套用替换(请预览后应用)`);
};
const buildReplacements = useCallback((): Replacement[] => {
const out: Replacement[] = [];
for (const g of groups) {
const to = (edits[g.address] ?? "").trim();
if (to && to !== g.address) out.push({ from: g.address, to, kind: g.kind });
}
return out;
}, [groups, edits]);
const pendingCount = useMemo(() => buildReplacements().length, [buildReplacements]);
const runPreview = async () => {
const repls = buildReplacements();
if (repls.length === 0) {
toast.error("还没有填写任何「替换为」");
return;
}
try {
const result = await previewReplacements(repls);
setPreview(result);
if (result.total === 0) toast.info("没有任何文件会被改动(请检查填写的替换值)");
} catch (err) {
toast.error(err instanceof Error ? err.message : "预览失败");
}
};
const runApply = async () => {
if (!preview) return;
const repls = buildReplacements();
if (repls.length === 0) return;
setApplying(true);
setApplyProg({ done: 0, totalFiles: 0 });
try {
const result = await applyReplacementsStream(repls, {
expectedTotal: preview.expectedTotal,
backup: true,
onProgress: setApplyProg,
});
toast.success(`已修改 ${result.changedFiles.length} 个文件,共替换 ${result.replacedTotal} 处`);
setLastBackupId(result.backupId);
setEdits({});
setPreview(null);
await refetchBackups();
await runScan(kinds);
} catch (err) {
toast.error(err instanceof Error ? err.message : "应用失败");
} finally {
setApplying(false);
setApplyProg(null);
}
};
const runRollback = async () => {
const id = lastBackupId ?? backups[0]?.backupId;
if (!id) {
toast.error("没有可撤销的备份");
return;
}
try {
const { restored } = await rollbackBackup(id);
toast.success(`已从备份恢复 ${restored} 个文件`);
setLastBackupId(null);
await refetchBackups();
await runScan(kinds);
} catch (err) {
toast.error(err instanceof Error ? err.message : "撤销失败");
}
};
const q = query.trim().toLowerCase();
const visible = useMemo(
() => (!q ? groups : groups.filter((g) => g.address.toLowerCase().includes(q) || g.occurrences.some((o) => o.skill.toLowerCase().includes(q)))),
[groups, q],
);
const totalOccurrences = useMemo(() => groups.reduce((s, g) => s + g.count, 0), [groups]);
const canRollback = !!(lastBackupId || backups.length > 0);
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
return (
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-5xl">
{/* 顶部信息卡 */}
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
<Replace className="size-44 text-slate-900" />
</div>
<div className="relative">
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-600">
<Wand2 className="size-3" />
技能维护工具
</span>
<h1 className="flex flex-wrap items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
技能地址管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{groups.length} 地址 / {totalOccurrences} 处
{scan ? ` / ${scan.scannedSkills} 技能` : ""}
</span>
</h1>
</div>
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={runRollback}
disabled={!canRollback || scanning || applying}
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
title="从最近一次的磁盘备份恢复"
>
<Undo2 className="size-[15px]" />
撤销上次
</button>
<button
type="button"
onClick={() => runScan(kinds)}
disabled={scanning || applying}
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-blue-600 bg-blue-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{scanning ? <Loader2 className="size-[15px] animate-spin" /> : <ScanLine className="size-[15px]" />}
{scanning ? "扫描中…" : "重新扫描"}
</button>
</div>
</div>
<p className="flex max-w-3xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>
扫描运行中的技能(<code className="rounded bg-slate-100 px-1">skills/public</code> 与{" "}
<code className="rounded bg-slate-100 px-1">skills/custom</code> 下所有 .md / .py),按<strong>唯一地址</strong>聚合其全部出现位置(跨 .md 与 .py)。填写「替换为」后<strong>预览</strong>命中再<strong>应用</strong>;应用前会自动磁盘备份,可一键<strong>撤销</strong>。替换为精确整串匹配,不会误伤子串。
</span>
</p>
</div>
</div>
{/* 进度条 */}
{scanning && scanProg && (
<div className="mb-6">
<ProgressBar done={scanProg.done} total={scanProg.total} label={`正在扫描技能${scanProg.skill ? `:${scanProg.skill}` : "…"}`} />
</div>
)}
{applying && applyProg && (
<div className="mb-6">
<ProgressBar done={applyProg.done} total={applyProg.totalFiles} label={`正在修改文件${applyProg.file ? `:${applyProg.file}` : "…"}`} />
</div>
)}
{/* 工具栏:类型过滤 + 搜索 + 片段批量套用 */}
<div className="mb-6 space-y-4 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-5">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div className="flex items-center gap-2">
{ALL_KINDS.map((k) => (
<button
key={k}
type="button"
onClick={() => toggleKind(k)}
disabled={scanning || applying}
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-all disabled:opacity-50 ${
kinds.includes(k)
? "border-blue-300 bg-blue-50 text-blue-700"
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300"
}`}
>
{k === "url" ? <Globe className="size-3.5" /> : <Network className="size-3.5" />}
{k === "url" ? "URL" : "IP"}
</button>
))}
</div>
<div className="relative w-full md:w-80">
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="搜索地址 / 技能名…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-blue-500 focus:outline-none focus:ring-4 focus:ring-blue-500/5"
/>
</div>
</div>
{/* 片段批量套用 */}
<div className="flex flex-col items-stretch gap-2.5 rounded-xl border border-dashed border-slate-200 bg-slate-50/50 p-3 md:flex-row md:items-center">
<span className="flex shrink-0 items-center gap-1.5 text-xs font-semibold text-slate-500">
<Wand2 className="size-3.5" />
片段批量套用
</span>
<input
type="text"
placeholder="查找片段,如 uat.4.cn"
value={segFind}
onChange={(e) => setSegFind(e.target.value)}
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10"
/>
<span className="shrink-0 text-slate-400">→</span>
<input
type="text"
placeholder="替换为,如 prod.4.cn"
value={segRepl}
onChange={(e) => setSegRepl(e.target.value)}
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10"
/>
<button
type="button"
onClick={applySegment}
className="inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3.5 py-1.5 text-sm font-semibold text-slate-700 transition-all hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
>
套用到匹配项
</button>
</div>
</div>
{/* 预览面板 */}
{preview && (
<div className="mb-6 overflow-hidden rounded-2xl border border-blue-200/70 bg-white shadow-sm">
<div className="flex flex-col items-start justify-between gap-3 border-b border-blue-100 bg-blue-50/50 px-4 py-3 md:flex-row md:items-center">
<div className="text-sm font-semibold text-slate-700">
预览:将改动 <span className="font-mono text-blue-700">{preview.total}</span> 处
({preview.items.filter((i) => i.count > 0).length} 个地址命中)
</div>
<button
type="button"
onClick={runApply}
disabled={applying || preview.total === 0}
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-blue-600 bg-blue-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{applying ? <Loader2 className="size-[15px] animate-spin" /> : <Replace className="size-[15px]" />}
{applying ? "应用中…" : "确认应用"}
</button>
</div>
{preview.warnings.length > 0 && (
<div className="border-b border-amber-100 bg-amber-50/60 px-4 py-2.5">
{preview.warnings.map((w, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs text-amber-700">
<AlertTriangle className="size-3.5 shrink-0" />
{w}
</div>
))}
</div>
)}
<ul className="max-h-72 divide-y divide-slate-100 overflow-auto">
{preview.items.map((it) => (
<li key={it.from} className="px-4 py-2.5 text-sm">
<div className="flex items-center gap-2">
<KindBadge kind={it.kind} />
<span className="font-mono text-slate-500 line-through">{it.from}</span>
<span className="text-slate-400">→</span>
<span className="font-mono font-medium text-blue-700">{it.to}</span>
<span className="ml-auto rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">{it.count} 处</span>
</div>
</li>
))}
</ul>
</div>
)}
{/* 地址列表 */}
<div className="overflow-hidden rounded-2xl border border-slate-200/60 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-200/60 bg-slate-50/70 px-4 py-2.5 text-xs font-semibold text-slate-500">
<span>地址({visible.length})</span>
{pendingCount > 0 && (
<button
type="button"
onClick={runPreview}
disabled={scanning || applying}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-blue-300 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700 transition-all hover:bg-blue-100 disabled:opacity-50"
>
<Eye className="size-3.5" />
预览改动({pendingCount})
</button>
)}
</div>
{scanning && !scan ? (
<div className="px-4 py-12 text-center text-sm text-slate-400">扫描中…</div>
) : visible.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-slate-400">
{groups.length === 0 ? "未发现任何 URL / IP 地址" : "没有匹配的地址"}
</div>
) : (
<ul className="divide-y divide-slate-100">
{visible.map((g) => (
<GroupRow
key={g.address}
group={g}
value={edits[g.address] ?? ""}
expanded={expanded.has(g.address)}
onToggle={() => toggleExpand(g.address)}
onChange={(v) => setEdit(g.address, v)}
/>
))}
</ul>
)}
</div>
</div>
</div>
);
}
function GroupRow({
group,
value,
expanded,
onToggle,
onChange,
}: {
group: AddressGroup;
value: string;
expanded: boolean;
onToggle: () => void;
onChange: (v: string) => void;
}) {
const dirty = value.trim() !== "" && value.trim() !== group.address;
return (
<li className={`px-4 py-3 transition-colors hover:bg-slate-50/60 ${dirty ? "bg-blue-50/30" : ""}`}>
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center">
<div className="flex min-w-0 flex-1 items-center gap-2">
<button type="button" onClick={onToggle} className="flex size-6 shrink-0 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600">
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
</button>
<KindBadge kind={group.kind} />
<span className="truncate font-mono text-sm text-slate-700" title={group.address}>
{group.address}
</span>
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">{group.count} 处</span>
</div>
<input
type="text"
value={value}
placeholder="替换为(留空 = 不改)"
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10 lg:w-96"
/>
</div>
{expanded && (
<ul className="mt-2.5 ml-8 space-y-1 border-l border-slate-100 pl-3">
{group.occurrences.map((o, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-slate-500">
<FileCode className="mt-0.5 size-3.5 shrink-0 text-slate-400" />
<div className="min-w-0">
<span className="font-medium text-slate-600">{o.skill}</span>
<span className="mx-1 text-slate-300">·</span>
<span className="font-mono">
{o.file}:{o.line}
</span>
<div className="truncate font-mono text-slate-400" title={o.snippet}>
{o.snippet}
</div>
</div>
</li>
))}
</ul>
)}
</li>
);
}
+11
View File
@@ -40,6 +40,7 @@ import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage";
import MenuManagementPage from "./MenuManagementPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
@@ -393,6 +394,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/skill-addresses"
element={
getStoredAuth()?.system_role === "admin" ? (
<SkillAddressesPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
@@ -0,0 +1,272 @@
/**
* 技能地址(URL/IP)批量替换 API 客户端 + React Query hooks。
*
* 后端:`/api/skill-addresses`(见 app/gateway/routers/skill_addresses.py)。
* 全部仅管理员可用。扫描/应用提供 **NDJSON 流式进度**端点(150+ 技能时显示
* 「已处理 X / 共 N」进度条),并保留一次性端点作为兜底。
* snake_case ⇄ camelCase 在此边界收敛。
*/
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
const BASE = () => `${getBackendBaseURL()}/api/skill-addresses`;
export type AddressKind = "url" | "ip";
export interface AddressOccurrence {
skill: string;
category: string; // "public" | "custom"
file: string; // 相对技能根的 POSIX 路径
line: number;
col: number;
snippet: string;
}
export interface AddressGroup {
address: string;
kind: AddressKind;
count: number;
occurrences: AddressOccurrence[];
}
export interface ScanResult {
groups: AddressGroup[];
scannedFiles: number;
scannedSkills: number;
kinds: string[];
scannedAt: string;
}
export interface Replacement {
from: string;
to: string;
kind?: AddressKind;
}
export interface PreviewFileHit {
file: string;
line: number;
before: string;
after: string;
}
export interface PreviewItem {
from: string;
to: string;
kind: AddressKind;
count: number;
files: PreviewFileHit[];
}
export interface PreviewResult {
items: PreviewItem[];
total: number;
warnings: string[];
expectedTotal: number;
}
export interface ChangedFile {
file: string;
count: number;
}
export interface ApplyResult {
changedFiles: ChangedFile[];
replacedTotal: number;
backupId: string | null;
warnings: string[];
}
export interface BackupEntry {
backupId: string;
files: number;
createdAt: string;
}
/** 扫描进度(逐技能)。 */
export interface ScanProgress {
done: number;
total: number;
skill?: string;
}
/** 应用进度(逐文件)。 */
export interface ApplyProgress {
done: number;
totalFiles: number;
file?: string;
}
// ── 错误处理 ───────────────────────────────────────────────────────────────
async function ensureOk(res: Response, ctx: string): Promise<void> {
if (res.ok) return;
const data = await res.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} 失败 (${res.status})`;
throw new Error(detail);
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
await ensureOk(res, ctx);
return (await res.json()) as T;
}
// ── 序列化辅助 ─────────────────────────────────────────────────────────────
function toScanResult(d: any): ScanResult {
return {
groups: (d.groups ?? []) as AddressGroup[],
scannedFiles: d.scanned_files ?? 0,
scannedSkills: d.scanned_skills ?? 0,
kinds: d.kinds ?? [],
scannedAt: d.scanned_at ?? "",
};
}
function toPreviewResult(d: any): PreviewResult {
return {
items: (d.items ?? []).map((i: any) => ({
from: i.from,
to: i.to,
kind: i.kind,
count: i.count ?? 0,
files: i.files ?? [],
})),
total: d.total ?? 0,
warnings: d.warnings ?? [],
expectedTotal: d.expected_total ?? 0,
};
}
function toApplyResult(d: any): ApplyResult {
return {
changedFiles: (d.changed_files ?? []).map((c: any) => ({ file: c.file, count: c.count })),
replacedTotal: d.replaced_total ?? 0,
backupId: d.backup_id ?? null,
warnings: d.warnings ?? [],
};
}
// ── NDJSON 流式读取 ────────────────────────────────────────────────────────
/** 逐行解析 NDJSON 流,对每个事件对象调用 onEvent。 */
async function readNdjson(res: Response, ctx: string, onEvent: (ev: any) => void): Promise<void> {
await ensureOk(res, ctx);
if (!res.body) throw new Error(`${ctx}:响应无数据流`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
const flush = (chunk: string) => {
buf += chunk;
let idx: number;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (line) onEvent(JSON.parse(line));
}
};
for (;;) {
const { done, value } = await reader.read();
if (done) break;
flush(decoder.decode(value, { stream: true }));
}
const tail = (buf + decoder.decode()).trim();
if (tail) onEvent(JSON.parse(tail));
}
// ── 端点 ───────────────────────────────────────────────────────────────────
/** 一次性扫描(兜底,无进度)。 */
export async function scanAddresses(kinds?: AddressKind[], skill?: string): Promise<ScanResult> {
const params = new URLSearchParams();
if (kinds && kinds.length) params.set("kinds", kinds.join(","));
if (skill) params.set("skill", skill);
const qs = params.toString();
const res = await apiFetch(`${BASE()}/scan${qs ? `?${qs}` : ""}`, { method: "GET" });
return toScanResult(await readJson<any>(res, "扫描"));
}
/** 流式扫描:逐技能上报进度,返回最终聚合结果。 */
export async function scanAddressesStream(
opts: { kinds?: AddressKind[]; skill?: string; onProgress?: (p: ScanProgress) => void } = {},
): Promise<ScanResult> {
const params = new URLSearchParams();
if (opts.kinds && opts.kinds.length) params.set("kinds", opts.kinds.join(","));
if (opts.skill) params.set("skill", opts.skill);
const qs = params.toString();
const res = await apiFetch(`${BASE()}/scan/stream${qs ? `?${qs}` : ""}`, { method: "GET" });
let result: ScanResult | null = null;
await readNdjson(res, "扫描", (ev) => {
if (ev.type === "start") opts.onProgress?.({ done: 0, total: ev.total ?? 0 });
else if (ev.type === "progress") opts.onProgress?.({ done: ev.done, total: ev.total, skill: ev.skill });
else if (ev.type === "result") result = toScanResult(ev);
});
if (!result) throw new Error("扫描未返回结果");
return result;
}
/** 干跑预览。 */
export async function previewReplacements(replacements: Replacement[]): Promise<PreviewResult> {
const res = await apiFetch(`${BASE()}/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ replacements }),
});
return toPreviewResult(await readJson<any>(res, "预览"));
}
/** 流式应用:逐文件上报进度,返回最终结果。`expectedTotal` 做乐观锁。 */
export async function applyReplacementsStream(
replacements: Replacement[],
opts: { expectedTotal?: number; backup?: boolean; onProgress?: (p: ApplyProgress) => void } = {},
): Promise<ApplyResult> {
const body = {
replacements,
expected_total: opts.expectedTotal ?? null,
backup: opts.backup ?? true,
};
const res = await apiFetch(`${BASE()}/apply/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
let result: ApplyResult | null = null;
await readNdjson(res, "应用", (ev) => {
if (ev.type === "start") opts.onProgress?.({ done: 0, totalFiles: ev.total_files ?? 0 });
else if (ev.type === "progress") opts.onProgress?.({ done: ev.done, totalFiles: ev.total_files, file: ev.file });
else if (ev.type === "result") result = toApplyResult(ev);
});
if (!result) throw new Error("应用未返回结果");
return result;
}
export async function listBackups(): Promise<BackupEntry[]> {
const res = await apiFetch(`${BASE()}/backups`, { method: "GET" });
const data = await readJson<{ backups: any[] }>(res, "读取备份");
return (data.backups ?? []).map((b) => ({ backupId: b.backup_id, files: b.files ?? 0, createdAt: b.created_at ?? "" }));
}
export async function rollbackBackup(backupId: string): Promise<{ restored: number }> {
const res = await apiFetch(`${BASE()}/rollback/${encodeURIComponent(backupId)}`, { method: "POST" });
const data = await readJson<{ restored_files: string[] }>(res, "撤销");
return { restored: (data.restored_files ?? []).length };
}
// ── React Query ────────────────────────────────────────────────────────────
export const SKILL_ADDRESS_BACKUPS_KEY = "skill-address-backups";
export function useSkillAddressBackups() {
return useQuery({
queryKey: [SKILL_ADDRESS_BACKUPS_KEY],
queryFn: listBackups,
refetchOnWindowFocus: false,
staleTime: 30_000,
});
}
@@ -343,7 +343,10 @@ function RwfxSqReportGate({ taskId }: { taskId: string }) {
暂不
</Button>
<Button
className="bg-[var(--color-main)] text-white hover:bg-[var(--brand-950)]"
// 弹框经 Radix Portal 渲染到 body,在 `.strategy-data` 作用域之外,故不能用
// 局部的 `--color-main`(红主题侥幸有值、蓝主题失效→按钮消失)。改用全局按主题
// 定义的品牌色 token(:root/.dark/.lightblue 都有),三套主题都能正确取色。
className="bg-[var(--workspace-brand)] text-[var(--workspace-brand-foreground)] hover:opacity-90"
onClick={handleGo3qfx}
disabled={jumping}
>
+40 -1
View File
@@ -243,7 +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). `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 `extra_body.chat_template_kwargs.enable_thinking=false` (+ GLM `thinking.type="disabled"`) even for internal vLLM/Qwen models that only declare `supports_thinking: true` — the same trick `TitleMiddleware._force_disable_thinking` uses for title generation. Sets `is_scheduled_run=true` internally to skip the auto-title LLM call. Router `app/gateway/routers/open_chat.py`; standalone caller `test_open_chat_api.py` (repo root); tests `tests/test_open_chat.py`. |
| **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. 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); `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`. |
@@ -527,6 +527,45 @@ 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`.
- **Frontend** — admin page `pages/SkillAddressesPage.tsx` (route
`/page/strategy/admin/skill-addresses`, entry「技能地址管理」in the bottom-left
settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes):
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, 应用,
and 撤销上次 (rollback). 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
@@ -69,6 +69,7 @@ from app.gateway.routers import (
runs,
scheduled_tasks,
sensitive_words,
skill_addresses,
skills,
suggestions,
system_settings,
@@ -1359,6 +1360,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)
@@ -33,12 +33,16 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
import uuid
from collections.abc import AsyncIterator
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
@@ -167,15 +171,15 @@ def _extract_answer(result: Any, *, strip_think: bool) -> str:
return answer
@router.post("/chat", response_model=OpenChatResponse)
async def open_chat(body: OpenChatRequest) -> OpenChatResponse:
"""指定模型 + 智能体做一次问答(无状态、可关闭思考)。"""
lc_messages = _build_lc_messages(body)
# 流式响应每隔这么久没有真实事件就发一个 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,
@@ -187,8 +191,26 @@ async def open_chat(body: OpenChatRequest) -> OpenChatResponse:
}
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)
@@ -211,6 +233,103 @@ async def open_chat(body: OpenChatRequest) -> OpenChatResponse:
)
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)
if not answer:
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,
},
)
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 该填什么)。"""
@@ -0,0 +1,631 @@
"""技能地址(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 BackupEntry(BaseModel):
backup_id: str
files: int
created_at: str
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)
# ── 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
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})
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())
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))
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)
@@ -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
@@ -134,10 +134,16 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
# 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"),
{"chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}},
{"enable_thinking": False, "chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}},
)
_enable_stream_usage_by_default(model_config.use, model_settings_from_config)
+6
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
+98 -30
View File
@@ -1,13 +1,16 @@
# -*- coding: utf-8 -*-
"""开放问答接口 /api/open/chat 测试脚本(Jupyter 版)。
"""开放问答接口测试脚本(流式版,Jupyter / 命令行均可)。
接口只返回「最终结果」:智能体把调研 / 工具调用全部跑完后,只把最后那段
文本(markdown)放在 answer 字段里返回 —— 不含中间过程、工具调用、思考。
文本(markdown)放在 answer 里返回 —— 不含中间过程、工具调用、思考。
用法:在 Jupyter 里把下面几段按顺序运行即可。
- 「配置」段:改成你要的模型 / 智能体 / 问题 / 是否思考
- 「调用」段:运行后 answer 变量就是最终 md 文本
为什么默认用流式(/api/open/chat/stream):
- 长任务(联网调研、出报告)同步阻塞那版整段没有下行字节,容易被前置 nginx 反代
的 proxy_read_timeout(默认 60s)掐成 504。流式版每隔几秒就有心跳/增量下行,
代理不会判定空闲超时,**多久都不会超时**。
- 客户端只需读到 `event: result` 解析 answer 即可,中途 delta/progress/心跳都忽略。
用法:改下面「配置」段,然后整段运行;answer 变量就是最终 md 文本。
无第三方依赖(纯标准库 urllib)。
"""
@@ -17,19 +20,22 @@ import urllib.error
import urllib.request
# ====================== 配置(按需修改这里) ======================
BASE_URL = "http://47.94.209.59:2026" # 后端地址(走 nginx 用 :2026,Gateway 直连用 :8001)
BASE_URL = "http://47.94.209.59:2026" # 走 nginx 用 :2026;Gateway 直连用 :8001
STREAM = True # True=流式(推荐,不超时);False=同步阻塞(短问题用)
MESSAGE = "帮我调研一下 XXX,输出 markdown 报告。" # 要问的问题
MODEL_NAME = None # 模型名(config.yaml models[].name);None=默认模型
AGENT_NAME = None # 智能体中文名称;None=默认 lead agent。不确定填啥就先跑下面的「列出智能体」
THINKING_ENABLED = False # False=关闭思考(内网模型也会被强制关闭);True=开启
TIMEOUT = 600 # 请求超时(秒)
MESSAGE = "帮我调研一下 赖清德,输出 markdown 报告。" # 要问的问题
MODEL_NAME = "deepseek-ai/DeepSeek-V4-Flash" # 模型名(config.yaml models[].name);None=默认模型
AGENT_NAME = "你好君" # 智能体中文名称;None=默认 lead agent。不确定填啥先跑 ping_agents()
THINKING_ENABLED = False # False=关闭思考(SiliconFlow/内网模型也会被强制关掉);True=开启
SHOW_LIVE = True # 流式时是否实时打印增量正文(看着它一点点出),只看结果可设 False
READ_TIMEOUT = 120 # 单次 socket 读超时(秒)。服务端每 ~15s 发心跳,故 120s 绰绰有余
TIMEOUT = 6000 # 同步(非流式)模式的整体超时(秒)
# ===============================================================
def open_chat(message, model_name=None, agent_name=None, thinking_enabled=False,
base_url=BASE_URL, timeout=TIMEOUT):
"""调用 /api/open/chat,返回最终 md 文本(只要结果,不要过程)。"""
"""同步阻塞版:整段跑完才返回。短问题用;长任务请用 open_chat_stream。"""
url = base_url.rstrip("/") + "/api/open/chat"
payload = {
"message": message,
@@ -39,8 +45,7 @@ def open_chat(message, model_name=None, agent_name=None, thinking_enabled=False,
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
url, data=data,
headers={"Content-Type": "application/json; charset=utf-8"},
method="POST",
)
@@ -49,24 +54,87 @@ def open_chat(message, model_name=None, agent_name=None, thinking_enabled=False,
return result["answer"] # 只返回最终结果
# ====================== (可选)列出可用智能体名称 ======================
# 不知道 AGENT_NAME 填什么时,先跑这段看有哪些中文名:
# import urllib.request, json
# with urllib.request.urlopen(BASE_URL.rstrip("/") + "/api/open/agents", timeout=30) as r:
# for a in json.loads(r.read().decode("utf-8"))["agents"]:
# print(a["name"])
def open_chat_stream(message, model_name=None, agent_name=None, thinking_enabled=False,
base_url=BASE_URL, read_timeout=READ_TIMEOUT, show_live=SHOW_LIVE):
"""流式版:读 SSE,边跑边收心跳/增量(不超时),最终从 event: result 取 answer。"""
url = base_url.rstrip("/") + "/api/open/chat/stream"
payload = {
"message": message,
"model_name": model_name,
"agent_name": agent_name,
"thinking_enabled": thinking_enabled,
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
url, data=data,
headers={"Content-Type": "application/json; charset=utf-8", "Accept": "text/event-stream"},
method="POST",
)
answer = None
error = None
event_name = None
data_lines = []
with urllib.request.urlopen(req, timeout=read_timeout) as resp:
for raw in resp: # 按行读取,服务端心跳让读取永不空闲超时
line = raw.decode("utf-8").rstrip("\r\n")
if line.startswith(":"): # SSE 注释行(心跳),忽略
continue
if line == "": # 空行 = 一个事件结束,分发
if data_lines:
payload_str = "\n".join(data_lines)
try:
obj = json.loads(payload_str)
except json.JSONDecodeError:
obj = {}
if event_name == "result":
answer = obj.get("answer")
elif event_name == "error":
error = obj.get("detail") or payload_str
elif event_name == "delta" and show_live:
print(obj.get("text", ""), end="", flush=True)
event_name, data_lines = None, []
continue
if line.startswith("event:"):
event_name = line[len("event:"):].strip()
elif line.startswith("data:"):
data_lines.append(line[len("data:"):].lstrip())
if error:
raise RuntimeError(f"接口返回错误:{error}")
if answer is None:
raise RuntimeError("未收到 event: result(流可能被中途断开)。")
return answer
def ping_agents(base_url=BASE_URL, timeout=30):
"""探活:GET /api/open/agents。秒回说明链路 OK,顺带看有哪些 agent_name 可填。"""
with urllib.request.urlopen(base_url.rstrip("/") + "/api/open/agents", timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
# ====================== 调用 ======================
_t0 = time.time()
answer = open_chat(MESSAGE, MODEL_NAME, AGENT_NAME, THINKING_ENABLED)
print(f"(耗时 {time.time() - _t0:.1f}s)\n")
print(answer) # answer 就是最终 markdown 文本
if __name__ == "__main__":
# 先探活(失败说明是链路/部署问题,而非模型问题)
try:
info = ping_agents()
print(f"[探活OK] 可用智能体 {info.get('count', 0)} 个\n")
except Exception as exc: # noqa: BLE001
print(f"[探活失败] {exc!r} —— 先排查 BASE_URL / 端口 / 服务是否在跑\n")
_t0 = time.time()
if STREAM:
answer = open_chat_stream(MESSAGE, MODEL_NAME, AGENT_NAME, THINKING_ENABLED)
else:
answer = open_chat(MESSAGE, MODEL_NAME, AGENT_NAME, THINKING_ENABLED)
print(f"\n\n(耗时 {time.time() - _t0:.1f}s)\n")
print(answer) # answer 就是最终 markdown 文本
# ====================== (可选)在 Jupyter 里渲染成 markdown ======================
# from IPython.display import Markdown, display
# display(Markdown(answer))
# ============ (可选)Jupyter 里渲染 markdown ============
# from IPython.display import Markdown, display
# display(Markdown(answer))
# ====================== (可选)存成 .md 文件 ======================
# with open("result.md", "w", encoding="utf-8") as f:
# f.write(answer)
# ============ (可选)存成 .md ============
# with open("result.md", "w", encoding="utf-8") as f:
# f.write(answer)