Merge branch 'ji' into master

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

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

No files matched your search

+50 -19
View File
@@ -3,6 +3,7 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
Replace,
SearchIcon,
SparklesIcon,
} from "lucide-react";
@@ -10,6 +11,7 @@ import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -19,6 +21,7 @@ import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { SkillAddressesPanel } from "./SkillAddressesPage";
const PAGE_SIZE = 10;
@@ -52,27 +55,47 @@ export default function AdminSkillsPage() {
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col">
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
<div>
<Tabs
defaultValue="skills"
className="flex size-full min-h-0 flex-col gap-0"
>
<div className="flex items-center justify-between gap-4 border-b px-6 pt-4">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">技能管理</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
</p>
</div>
<div className="relative w-64 shrink-0">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="按名称搜索"
className="pl-8"
/>
<TabsList>
<TabsTrigger value="skills">
<SparklesIcon />
技能列表
</TabsTrigger>
<TabsTrigger value="addresses">
<Replace />
技能地址管理
</TabsTrigger>
</TabsList>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
{isLoading && (
<TabsContent
value="skills"
className="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden"
>
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
<p className="text-muted-foreground text-sm">
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
</p>
<div className="relative w-64 shrink-0">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="按名称搜索"
className="pl-8"
/>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
{isLoading && (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
加载中…
</div>
@@ -132,8 +155,16 @@ export default function AdminSkillsPage() {
</Button>
</div>
)}
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="addresses"
className="mt-0 min-h-0 flex-1 overflow-hidden"
>
<SkillAddressesPanel />
</TabsContent>
</Tabs>
</WorkspaceBody>
</WorkspaceContainer>
);
+1
View File
@@ -469,6 +469,7 @@ export default function AgentChatPage({
disabled={thread.isLoading || isDemoMode}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
defaultCollapsed={taskCtxOn}
showRecentChats={!taskCtxOn}
/>
</div>
</ThreadContext.Provider>
+58 -5
View File
@@ -7,7 +7,7 @@ import {
SaveIcon,
SearchIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -48,6 +48,7 @@ import {
updateAgent,
} from "@/core/agents/api";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { isAgentSkillCandidate, type Skill } from "@/core/skills";
@@ -75,7 +76,11 @@ function getCreateAgentErrorMessage(
export default function NewAgentPage() {
const { t } = useI18n();
const router = useRouter();
const threadId = useMemo(() => uuid(), []);
// 从「常用对话」点击某条智能体创建(bootstrap)会话回到本页时,URL 带 thread_id,
// 用它恢复该会话(加载历史消息、继续对话);正常进入则新开一个会话。
const { thread_id: resumeThreadId } = useParams<{ thread_id?: string }>();
const generatedThreadId = useMemo(() => uuid(), []);
const threadId = resumeThreadId || generatedThreadId;
const [nameInput, setNameInput] = useState("");
const [descriptionInput, setDescriptionInput] = useState("");
@@ -94,10 +99,15 @@ export default function NewAgentPage() {
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [skillSearch, setSkillSearch] = useState("");
const [threadPrepared, setThreadPrepared] = useState(false);
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
const [threadPrepared, setThreadPrepared] = useState(!!resumeThreadId);
const { skills, isLoading: isSkillsLoading } = useSkills();
const { models, isLoading: isModelsLoading } = useModels();
// 创建请求的归属账号取自「实时」token(apiFetch 每次都重读 localStorage),而非
// 本页加载时缓存的身份 —— 用 live 账号提示归属并检测账号是否在会话中途被切换。
const live = useLiveAccount();
// 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」,
// 其他用户未发布的自定义技能不可选。
const storedAuth = useMemo(() => getStoredAuth(), []);
@@ -292,13 +302,37 @@ export default function NewAgentPage() {
]);
const handleSaveAgent = useCallback(async () => {
// 新建时归属账号 = 创建请求所用的实时 token 对应账号。若本页加载后登录被切换
// (常见于另一标签页登录了别的账号),现在创建会把智能体静默归到新账号名下。
// 先弹确认,避免「用 A 账号创建却归到 B 账号」。更新已有智能体不改归属,故跳过。
if (!agentId && live.switched) {
const ok = window.confirm(
`当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` +
`现在创建将把该智能体归属到「${live.displayName}」名下。\n` +
`如需归属「${live.mountDisplayName}」,请取消并刷新页面后重新登录。\n\n仍要以「${live.displayName}」创建吗?`,
);
if (!ok) {
return;
}
}
const saved = await ensureAgent();
if (!saved) {
return;
}
toast.success(`智能体「${saved.name}」已保存`);
toast.success(
agentId
? `智能体「${saved.name}」已保存`
: `智能体「${saved.name}」已创建(归属账号:${live.displayName})`,
);
router.push("/page/workspace/agents");
}, [ensureAgent, router]);
}, [
agentId,
ensureAgent,
live.displayName,
live.mountDisplayName,
live.switched,
router,
]);
const handleChatSubmit = useCallback(
async (text: string) => {
@@ -666,6 +700,25 @@ export default function NewAgentPage() {
<p className="text-destructive text-sm">{formError}</p>
) : null}
<div className="rounded-md border px-3 py-2 text-xs dark:border-[#1B477E] dark:bg-[#022248]">
<p className="text-muted-foreground">
{agentId ? "当前登录账号:" : "创建后归属账号:"}
<span className="text-foreground font-medium">
「{live.displayName}」
</span>
</p>
{live.switched && (
<p className="text-destructive mt-1">
⚠ 本页加载时为「{live.mountDisplayName}」,现已切换为「
{live.displayName}」。
{agentId
? "继续保存将以当前账号执行。"
: `继续创建将归属「${live.displayName}」。`}
建议刷新页面确认账号。
</p>
)}
</div>
<Button
className="w-full"
onClick={() => void handleSaveAgent()}
@@ -0,0 +1,655 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Eye,
FileCode,
Globe,
History,
Info,
Loader2,
Network,
Replace,
ScanLine,
Search,
Undo2,
Wand2,
X,
} 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 function SkillAddressesPanel() {
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 [showHistory, setShowHistory] = useState(false);
const [confirmRollbackId, setConfirmRollbackId] = useState<string | null>(null);
const [rollingBack, setRollingBack] = useState(false);
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);
setShowHistory(true); // 应用后自动展开修改记录,便于查看/回退本次
await refetchBackups();
await runScan(kinds);
} catch (err) {
toast.error(err instanceof Error ? err.message : "应用失败");
} finally {
setApplying(false);
setApplyProg(null);
}
};
/** 回退单次修改记录:从该次备份恢复文件(不影响其他记录)。 */
const rollbackOne = async (backupId: string) => {
setRollingBack(true);
try {
const { restored } = await rollbackBackup(backupId);
toast.success(`已从该记录回退 ${restored} 个文件`);
setConfirmRollbackId(null);
if (backupId === lastBackupId) setLastBackupId(null);
await refetchBackups();
await runScan(kinds);
} catch (err) {
toast.error(err instanceof Error ? err.message : "回退失败");
} finally {
setRollingBack(false);
}
};
const fmtTime = (iso: string) => {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
// 多关键词检索:空格分隔,每个关键词都要命中(地址或技能名任一包含)才显示。
// 这样「uat 4.cn」能同时收窄到既含 uat 又含 4.cn 的地址,应对地址过多的场景。
const tokens = useMemo(
() => query.trim().toLowerCase().split(/\s+/).filter(Boolean),
[query],
);
const visible = useMemo(() => {
if (tokens.length === 0) return groups;
return groups.filter((g) => {
const haystack = `${g.address} ${g.occurrences.map((o) => o.skill).join(" ")}`.toLowerCase();
return tokens.every((t) => haystack.includes(t));
});
}, [groups, tokens]);
const totalOccurrences = useMemo(() => groups.reduce((s, g) => s + g.count, 0), [groups]);
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={() => setShowHistory((v) => !v)}
className={`inline-flex cursor-pointer items-center gap-2 rounded-xl border px-3.5 py-2 text-sm font-semibold shadow-sm transition-all ${
showHistory
? "border-amber-300 bg-amber-50 text-amber-700"
: "border-slate-200 bg-white text-slate-600 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700"
}`}
title="查看每次修改记录,可针对单次回退"
>
<History className="size-[15px]" />
修改记录{backups.length > 0 ? `(${backups.length})` : ""}
</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>,可在「修改记录」里针对<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>
)}
{/* 修改记录:每次应用一条,可针对单次回退 */}
{showHistory && (
<div className="mb-6 overflow-hidden rounded-2xl border border-amber-200/70 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-amber-100 bg-amber-50/50 px-4 py-3 text-sm font-semibold text-slate-700">
<span className="flex items-center gap-2">
<History className="size-4 text-amber-600" />
修改记录({backups.length})
</span>
<button
type="button"
onClick={() => refetchBackups()}
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
>
刷新
</button>
</div>
{backups.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-slate-400">
暂无修改记录。应用一次替换后,这里会生成一条可回退的记录。
</div>
) : (
<ul className="max-h-96 divide-y divide-slate-100 overflow-auto">
{backups.map((b) => (
<li key={b.backupId} className="px-4 py-3">
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
{fmtTime(b.createdAt)}
{b.backupId === lastBackupId && (
<span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-700">最近</span>
)}
<span className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">
{b.files} 文件 / {b.replacedTotal} 处
</span>
</div>
{b.replacements.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{b.replacements.slice(0, 4).map((r, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded-md bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-500">
<span className="line-through">{r.from}</span>
<span className="text-slate-400">→</span>
<span className="font-medium text-blue-700">{r.to}</span>
</span>
))}
{b.replacements.length > 4 && (
<span className="text-[11px] text-slate-400">等 {b.replacements.length} 项</span>
)}
</div>
) : (
<div className="mt-1 font-mono text-xs text-slate-400">{b.backupId}</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{confirmRollbackId === b.backupId ? (
<>
<button
type="button"
onClick={() => rollbackOne(b.backupId)}
disabled={rollingBack || scanning || applying}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-amber-500 bg-amber-500 px-3 py-1.5 text-xs font-semibold text-white transition-all hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{rollingBack ? <Loader2 className="size-3.5 animate-spin" /> : <Undo2 className="size-3.5" />}
确认回退
</button>
<button
type="button"
onClick={() => setConfirmRollbackId(null)}
disabled={rollingBack}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
>
取消
</button>
</>
) : (
<button
type="button"
onClick={() => setConfirmRollbackId(b.backupId)}
disabled={scanning || applying}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 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-3.5" />
回退此次
</button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</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-96">
<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-9 text-sm transition-all hover:border-slate-300 focus:border-blue-500 focus:outline-none focus:ring-4 focus:ring-blue-500/5"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
title="清除"
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" />
</button>
)}
</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}{tokens.length > 0 ? ` / ${groups.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>
);
}
export default function SkillAddressesPage() {
return <SkillAddressesPanel />;
}
function GroupRow({
group,
value,
expanded,
onToggle,
onChange,
}: {
group: AddressGroup;
value: string;
expanded: boolean;
onToggle: () => void;
onChange: (v: string) => void;
}) {
const dirty = value.trim() !== "" && value.trim() !== group.address;
return (
<li className={`px-4 py-3 transition-colors hover:bg-slate-50/60 ${dirty ? "bg-blue-50/30" : ""}`}>
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center">
<div className="flex min-w-0 flex-1 items-center gap-2">
<button type="button" onClick={onToggle} className="flex size-6 shrink-0 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600">
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
</button>
<KindBadge kind={group.kind} />
<span className="truncate font-mono text-sm text-slate-700" title={group.address}>
{group.address}
</span>
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">{group.count} 处</span>
</div>
<input
type="text"
value={value}
placeholder="替换为(留空 = 不改)"
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10 lg:w-96"
/>
</div>
{expanded && (
<ul className="mt-2.5 ml-8 space-y-1 border-l border-slate-100 pl-3">
{group.occurrences.map((o, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-slate-500">
<FileCode className="mt-0.5 size-3.5 shrink-0 text-slate-400" />
<div className="min-w-0">
<span className="font-medium text-slate-600">{o.skill}</span>
<span className="mx-1 text-slate-300">·</span>
<span className="font-mono">
{o.file}:{o.line}
</span>
<div className="truncate font-mono text-slate-400" title={o.snippet}>
{o.snippet}
</div>
</div>
</li>
))}
</ul>
)}
</li>
);
}
+11
View File
@@ -40,6 +40,7 @@ import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage";
import MenuManagementPage from "./MenuManagementPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage";
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
@@ -404,6 +405,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={
@@ -57,6 +57,12 @@ function NotebookDetailPageWithKey() {
return <StudioNotebookDetailPage key={notebookId} />;
}
// 恢复某条 bootstrap 会话时按 thread_id 重挂,切换会话不串台。
function NewAgentPageWithKey() {
const { thread_id } = useParams<{ thread_id: string }>();
return <NewAgentPage key={thread_id} />;
}
function isInIframe(): boolean {
try {
return window.self !== window.top;
@@ -279,6 +285,16 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
{/* 从「常用对话」点击智能体创建(bootstrap)会话回到创建页并恢复该会话。
key 绑定 thread_id —— 切换不同 bootstrap 会话时重挂一个干净的页面。 */}
<Route
path="agents/new/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<NewAgentPageWithKey />
</WorkspaceLayout>
}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/workspace/agents" replace />}