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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -469,6 +469,7 @@ export default function AgentChatPage({
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
defaultCollapsed={taskCtxOn}
|
||||
showRecentChats={!taskCtxOn}
|
||||
/>
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
Reference in new issue
Block a user