Merge branch 'ji' into master
合并 ji 的功能:开放问答接口(/api/open/chat[/stream] 回传生成文件 + 临时不留存)、 技能地址管理、智能体详情「最近对话」等。 冲突 frontend StrategyRoutes.tsx 已解决:同时保留 admin/roundtable-diagnostics(master) 与 admin/skill-addresses(ji) 两个管理员路由。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
fbec222eb4
38 files changed
+3365
-94
No files matched your search
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 技能地址(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 BackupReplacement {
|
||||
from: string;
|
||||
to: string;
|
||||
kind: AddressKind;
|
||||
}
|
||||
|
||||
export interface BackupEntry {
|
||||
backupId: string;
|
||||
files: number;
|
||||
createdAt: string;
|
||||
replacedTotal: number;
|
||||
replacements: BackupReplacement[];
|
||||
}
|
||||
|
||||
/** 扫描进度(逐技能)。 */
|
||||
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 ?? "",
|
||||
replacedTotal: b.replaced_total ?? 0,
|
||||
replacements: (b.replacements ?? []).map((r: any) => ({ from: r.from, to: r.to, kind: r.kind })),
|
||||
}));
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user