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:
1 parent
32476c7ad4
commit
6b480fdbe9
15 files changed
+1749
-40
No files matched your search
@@ -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__/
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user