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
@@ -77,6 +77,7 @@ import {
|
||||
import { getAgent } from "@/core/agents/api";
|
||||
import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
@@ -250,6 +251,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
// 发布请求实际以「实时」token 执行 —— 用 live 账号来提示发布者并检测账号是否被切换。
|
||||
const live = useLiveAccount();
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
const isBuiltin = agent.builtin;
|
||||
const isOwned =
|
||||
@@ -424,18 +427,38 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
setPublishPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { published: !agent.published },
|
||||
});
|
||||
toast.success(agent.published ? "已取消发布" : "已发布");
|
||||
toast.success(
|
||||
agent.published ? "已取消发布" : `已发布(操作账号:${live.displayName})`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// 发布操作以实时 token 执行。若会话中途账号被切换(常见于另一标签页登录了别的
|
||||
// 账号),先弹一次确认 —— 避免误以本人之外的账号执行发布。返回 false = 用户取消。
|
||||
function warnIfAccountSwitched(): boolean {
|
||||
if (!live.switched) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm(
|
||||
`当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` +
|
||||
`本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePublishToSquare(squareId: string) {
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -443,7 +466,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
});
|
||||
const sqName =
|
||||
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
|
||||
toast.success(`已发布到「${sqName}」`);
|
||||
toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`);
|
||||
setPublishPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -1117,6 +1140,23 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
loading={updateAgent.isPending}
|
||||
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
|
||||
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||
accountNotice={
|
||||
<div className="text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
发布操作账号:
|
||||
<span className="text-foreground font-medium">
|
||||
「{live.displayName}」
|
||||
</span>
|
||||
</p>
|
||||
{live.switched && (
|
||||
<p className="text-destructive mt-1">
|
||||
⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「
|
||||
{live.displayName}」。如需以「{live.mountDisplayName}
|
||||
」操作,请刷新页面确认。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -70,6 +70,8 @@ import {
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
bootstrapResumePath,
|
||||
isBootstrapThread,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
sortThreadsPinnedFirst,
|
||||
@@ -338,14 +340,14 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||
|
||||
// 排除调度/引导/会商类会话,与首页一致;置顶优先。
|
||||
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
|
||||
// (带机器人图标、点击回创建页);置顶优先。
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
@@ -450,7 +452,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
</div>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const href = pathOf(thread);
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||
const isActive = href === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
@@ -468,12 +471,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
{isBoot ? (
|
||||
<Bot
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{pinned && (
|
||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
type UpdateAgentRequest,
|
||||
} from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { isAgentSkillCandidate } from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
@@ -135,6 +137,7 @@ export function AgentDetailPanel({
|
||||
onAskQuestion,
|
||||
disabled,
|
||||
defaultCollapsed,
|
||||
showRecentChats,
|
||||
}: {
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
@@ -142,6 +145,8 @@ export function AgentDetailPanel({
|
||||
disabled?: boolean;
|
||||
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
|
||||
defaultCollapsed?: boolean;
|
||||
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
|
||||
showRecentChats?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
||||
@@ -159,6 +164,8 @@ export function AgentDetailPanel({
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
// 发布以实时 token 执行 —— 用 live 账号提示发布者并检测账号是否被切换。
|
||||
const live = useLiveAccount();
|
||||
|
||||
const updateAgent = useUpdateAgent();
|
||||
const deleteAgent = useDeleteAgent();
|
||||
@@ -343,19 +350,39 @@ export function AgentDetailPanel({
|
||||
setPublishPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { published: !agent.published },
|
||||
});
|
||||
toast.success(agent.published ? "已下架" : "已发布");
|
||||
toast.success(
|
||||
agent.published ? "已下架" : `已发布(操作账号:${live.displayName})`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// 发布以实时 token 执行。会话中途账号被切换(常见于另一标签页登录了别的账号)时
|
||||
// 先弹确认,避免误以本人之外的账号执行发布。返回 false = 用户取消。
|
||||
function warnIfAccountSwitched(): boolean {
|
||||
if (!live.switched) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm(
|
||||
`当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` +
|
||||
`本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePublishToSquare(squareId: string) {
|
||||
if (!agent) return;
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -363,7 +390,7 @@ export function AgentDetailPanel({
|
||||
});
|
||||
const sqName =
|
||||
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
|
||||
toast.success(`已发布到「${sqName}」`);
|
||||
toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`);
|
||||
setPublishPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -791,6 +818,9 @@ export function AgentDetailPanel({
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -921,6 +951,23 @@ export function AgentDetailPanel({
|
||||
loading={updateAgent.isPending}
|
||||
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
|
||||
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||
accountNotice={
|
||||
<div className="text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
发布操作账号:
|
||||
<span className="text-foreground font-medium">
|
||||
「{live.displayName}」
|
||||
</span>
|
||||
</p>
|
||||
{live.switched && (
|
||||
<p className="text-destructive mt-1">
|
||||
⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「
|
||||
{live.displayName}」。如需以「{live.mountDisplayName}
|
||||
」操作,请刷新页面确认。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useThreadsPage } from "@/core/threads/hooks";
|
||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RECENT_LIMIT = 8;
|
||||
|
||||
/**
|
||||
* 「有关此智能体的最近对话」—— 渲染在智能体详情面板底部(右下角)。
|
||||
* 按 `metadata.agent_id` 过滤出该智能体的会话,点击直接打开对应对话。
|
||||
*/
|
||||
export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
const pathname = usePathname();
|
||||
const { data } = useThreadsPage({
|
||||
page: 1,
|
||||
pageSize: RECENT_LIMIT,
|
||||
metadata: { agent_id: agentId },
|
||||
});
|
||||
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。
|
||||
const threads = (data?.threads ?? []).filter(
|
||||
(th) =>
|
||||
th.metadata?.thread_type !== "bootstrap" &&
|
||||
th.metadata?.thread_type !== "scheduler" &&
|
||||
th.metadata?.thread_type !== "roundtable",
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
<MessageSquareIcon className="size-3.5" />
|
||||
最近对话
|
||||
</h4>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{threads.length} 个
|
||||
</span>
|
||||
</div>
|
||||
{threads.length === 0 ? (
|
||||
<p className="text-muted-foreground/60 text-sm">暂无对话</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{threads.map((thread) => {
|
||||
const href = pathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
return (
|
||||
<Link
|
||||
key={thread.thread_id}
|
||||
href={href}
|
||||
title={desensitize(titleOfThread(thread, "新对话"))}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary/5 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, "新对话"))}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -75,8 +75,10 @@ import {
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
listPathOfThread,
|
||||
pathOfThread,
|
||||
sortThreadsPinnedFirst,
|
||||
titleOfThread,
|
||||
@@ -213,11 +215,12 @@ export function GeneralQaSidebar() {
|
||||
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
|
||||
// 仅排除调度 / 会商类系统会话。
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
@@ -249,9 +252,9 @@ export function GeneralQaSidebar() {
|
||||
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
@@ -417,7 +420,9 @@ export function GeneralQaSidebar() {
|
||||
</div>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const href = listPathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -426,7 +431,7 @@ export function GeneralQaSidebar() {
|
||||
className="group/item relative flex items-center"
|
||||
>
|
||||
<Link
|
||||
href={pathOfThread(thread)}
|
||||
href={href}
|
||||
className={cn(
|
||||
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||
isActive
|
||||
@@ -434,12 +439,22 @@ export function GeneralQaSidebar() {
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
{isBoot ? (
|
||||
<Bot
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -26,6 +26,8 @@ interface PublishSquarePickerDialogProps {
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** 可选提示节点,渲染在描述下方 —— 用于显示「本次发布将以哪个账号进行」及账号切换告警。 */
|
||||
accountNotice?: ReactNode;
|
||||
}
|
||||
|
||||
/** A "publish to which square (发布广场)?" chooser. Squares are rendered as a
|
||||
@@ -39,6 +41,7 @@ export function PublishSquarePickerDialog({
|
||||
loading,
|
||||
title = "选择发布广场",
|
||||
description = "请选择要把它发布到哪个广场。每个项目只能归属一个广场,不会重复出现。",
|
||||
accountNotice,
|
||||
}: PublishSquarePickerDialogProps) {
|
||||
const [selected, setSelected] = useState<string>(
|
||||
defaultSquareId || squares[0]?.id || "",
|
||||
@@ -58,6 +61,7 @@ export function PublishSquarePickerDialog({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{accountNotice}
|
||||
<div className="max-h-[50vh] space-y-1.5 overflow-y-auto py-1">
|
||||
{squares.map((sq) => {
|
||||
const active = selected === sq.id;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
@@ -64,8 +65,10 @@ import {
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
listPathOfThread,
|
||||
pathOfThread,
|
||||
sortThreadsPinnedFirst,
|
||||
titleOfThread,
|
||||
@@ -130,9 +133,9 @@ export function RecentChatList({
|
||||
});
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
@@ -189,11 +192,12 @@ export function RecentChatList({
|
||||
[t],
|
||||
);
|
||||
|
||||
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
|
||||
// 仅排除调度 / 会商类系统会话。
|
||||
const visibleThreads = sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
);
|
||||
@@ -239,7 +243,9 @@ export function RecentChatList({
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{shownThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const href = listPathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -261,17 +267,29 @@ export function RecentChatList({
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
href={href}
|
||||
>
|
||||
{compact && (
|
||||
<MessageSquare
|
||||
{isBoot ? (
|
||||
<Bot
|
||||
className={
|
||||
"size-3.5 shrink-0 " +
|
||||
(isActive
|
||||
? "text-[var(--primary)]"
|
||||
: "text-muted-foreground/70")
|
||||
}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : (
|
||||
compact && (
|
||||
<MessageSquare
|
||||
className={
|
||||
"size-3.5 shrink-0 " +
|
||||
(isActive
|
||||
? "text-[var(--primary)]"
|
||||
: "text-muted-foreground/70")
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
|
||||
|
||||
/** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */
|
||||
export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson";
|
||||
@@ -221,3 +222,43 @@ export async function fetchDashboardInputJson(
|
||||
if (resolved) persistDashboardInputJson(resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */
|
||||
|
||||
/**
|
||||
* 「3q(研判情报报告)是否完成」状态:
|
||||
* - ``complete`` = selectSqReport 数组里有有效内容;
|
||||
* - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成);
|
||||
* - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。
|
||||
*/
|
||||
export type SqReportStatus = "complete" | "incomplete" | "unknown";
|
||||
|
||||
/**
|
||||
* rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport``
|
||||
* 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。
|
||||
* - 有可用内容 → ``complete``(正常);
|
||||
* - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx);
|
||||
* - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。
|
||||
*
|
||||
* **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时):
|
||||
* - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求**
|
||||
* (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]);
|
||||
* - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。
|
||||
*/
|
||||
export async function fetchSqReportStatus(taskId: string): Promise<SqReportStatus> {
|
||||
if (!taskId) return "unknown";
|
||||
const cfg = await fetchTaskDeeplinkConfig();
|
||||
// test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。
|
||||
if (cfg.test) {
|
||||
return extractSqReport(cfg.mock.sqReport, taskId) ? "complete" : "incomplete";
|
||||
}
|
||||
// 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。
|
||||
const url = sqReportUrl();
|
||||
if (!url) return "unknown";
|
||||
try {
|
||||
const body = await requestCrudService.get(url, { taskId });
|
||||
return extractSqReport(body, taskId) ? "complete" : "incomplete";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,13 @@ export interface TaskDeeplinkConfig {
|
||||
/** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */
|
||||
detail3qUrl: string;
|
||||
/** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success};
|
||||
* actionDetail = 原始 {state,msg,data:{taskId,...}}。 */
|
||||
* actionDetail = 原始 {state,msg,data:{taskId,...}};sqReport = 原始 {state,msg,data:[...]}
|
||||
* (rwfx 3q 预检用,test 为真时生效)。 */
|
||||
mock: {
|
||||
purposeDetail?: unknown;
|
||||
delete?: { message?: string; success?: boolean };
|
||||
actionDetail?: unknown;
|
||||
sqReport?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,6 +83,7 @@ function normalize(body: unknown): TaskDeeplinkConfig {
|
||||
? (mock.delete as { message?: string; success?: boolean })
|
||||
: undefined,
|
||||
actionDetail: mock.action_detail,
|
||||
sqReport: mock.sq_report,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { isEmbedActive } from "@/core/embed/embed-session";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
export interface LiveAccount {
|
||||
/** 实时登录用户 id(随 storage/focus 刷新)。null = 未登录。 */
|
||||
userId: string | null;
|
||||
/** 实时显示名(邮箱 @ 前缀,否则 user_id)。 */
|
||||
displayName: string;
|
||||
/** 组件首次挂载时捕获的用户 id。 */
|
||||
mountUserId: string | null;
|
||||
/** 组件首次挂载时捕获的显示名。 */
|
||||
mountDisplayName: string;
|
||||
/**
|
||||
* 当前登录账号与挂载时不一致 —— 即本视图加载后登录被切换过(通常是在另一个
|
||||
* 标签页登录了别的账号)。归属/发布类操作必须据此提醒,避免误归属。
|
||||
*/
|
||||
switched: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟踪「实时」登录账号,而不是挂载时缓存的值。
|
||||
*
|
||||
* 鉴权 token 存在 localStorage(非嵌入态下被同浏览器所有标签页共享),所以在
|
||||
* 另一个标签页登录会**静默替换**本标签页 API 调用所用的 token。任何会决定归属的
|
||||
* 界面(创建/发布智能体)都必须显示请求实际将以哪个账号执行,并在会话中途被切换
|
||||
* 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。
|
||||
*/
|
||||
export function useLiveAccount(): LiveAccount {
|
||||
const mountRef = useRef<{ id: string | null; name: string } | null>(null);
|
||||
if (mountRef.current === null) {
|
||||
const auth = getStoredAuth();
|
||||
mountRef.current = {
|
||||
id: auth?.user_id ?? null,
|
||||
name: getAccountDisplayName(auth),
|
||||
};
|
||||
}
|
||||
const [live, setLive] = useState(() => mountRef.current!);
|
||||
|
||||
useEffect(() => {
|
||||
function refresh() {
|
||||
const auth = getStoredAuth();
|
||||
const id = auth?.user_id ?? null;
|
||||
const name = getAccountDisplayName(auth);
|
||||
setLive((prev) =>
|
||||
prev.id === id && prev.name === name ? prev : { id, name },
|
||||
);
|
||||
}
|
||||
// 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件
|
||||
// —— 只挂在正常态;focus/visibilitychange 兜底刷新两种形态都有效。
|
||||
function onStorage(e: StorageEvent) {
|
||||
if (e.key === AUTH_STORAGE_KEY) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
if (!isEmbedActive()) {
|
||||
window.addEventListener("storage", onStorage);
|
||||
}
|
||||
window.addEventListener("focus", refresh);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
refresh();
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("focus", refresh);
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mount = mountRef.current!;
|
||||
return {
|
||||
userId: live.id,
|
||||
displayName: live.name,
|
||||
mountUserId: mount.id,
|
||||
mountDisplayName: mount.name,
|
||||
switched: live.id !== null && mount.id !== null && live.id !== mount.id,
|
||||
};
|
||||
}
|
||||
@@ -81,6 +81,32 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体创建过程产生的「引导会话」(bootstrap)。这类会话用 metadata.thread_type
|
||||
* === "bootstrap" 标记,点击后应回到「创建智能体」页继续,而不是普通问答页。
|
||||
*/
|
||||
export function isBootstrapThread(thread: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): boolean {
|
||||
return thread.metadata?.thread_type === "bootstrap";
|
||||
}
|
||||
|
||||
/**
|
||||
* bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在
|
||||
* /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定,
|
||||
* 不随当前 route base 变化。
|
||||
*/
|
||||
export function bootstrapResumePath(threadId: string): string {
|
||||
return `/page/workspace/agents/new/${threadId}`;
|
||||
}
|
||||
|
||||
/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */
|
||||
export function listPathOfThread(thread: AgentThread): string {
|
||||
return isBootstrapThread(thread)
|
||||
? bootstrapResumePath(thread.thread_id)
|
||||
: pathOfThread(thread);
|
||||
}
|
||||
|
||||
export function isThreadPinned(thread: AgentThread): boolean {
|
||||
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
+85
-1
@@ -30,7 +30,13 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
|
||||
import { fetchActionDetail, readCopTaskDetail, type ActionDetail } from "@/core/auth/task-deeplink";
|
||||
import {
|
||||
fetchActionDetail,
|
||||
readCopTaskDetail,
|
||||
resolveTaskDeeplinkTarget,
|
||||
type ActionDetail,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
|
||||
import { TASK_BUSINESSES, type TaskBusiness } from "@/core/auth/task-buttons";
|
||||
import { useTaskThreads } from "@/core/threads/use-task-threads";
|
||||
import { titleOfThread } from "@/core/threads/utils";
|
||||
@@ -276,6 +282,82 @@ function TaskClearButton({ taskId }: { taskId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* rwfx 专用「3q 完成预检」闸门:进入任务分析工作区时调研判情报报告接口
|
||||
* (``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``)。若该任务的报告为空
|
||||
* (数组为空 / 内容全为 null,即 3q 未完成)→ 弹框提示「3q 未完成,是否进行 3qfx」,
|
||||
* 并提供「前往 3qfx」按钮切换到**同一任务**的三情分析工作区(复用深链解析重绑任务上下文)。
|
||||
* 报告正常 / 接口不可判定(缺地址、请求失败)时不打扰用户。见 [[dashboard-input]] / [[task-deeplink]]。
|
||||
*/
|
||||
function RwfxSqReportGate({ taskId }: { taskId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [jumping, setJumping] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
let alive = true;
|
||||
void fetchSqReportStatus(taskId).then((status) => {
|
||||
// 仅「明确为空(3q 未完成)」才弹框;complete / unknown 静默放行。
|
||||
if (alive && status === "incomplete") setOpen(true);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const handleGo3qfx = async () => {
|
||||
const detail = readCopTaskDetail();
|
||||
if (!detail || !taskId) {
|
||||
toast.error("缺少任务信息,无法跳转");
|
||||
return;
|
||||
}
|
||||
setJumping(true);
|
||||
try {
|
||||
// 复用深链解析:会清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。
|
||||
const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId);
|
||||
if (target) {
|
||||
setOpen(false);
|
||||
navigate(target.path, { state: target.state });
|
||||
} else {
|
||||
toast.error("未配置三情分析(3qfx)对应的业务智能体");
|
||||
}
|
||||
} catch {
|
||||
toast.error("跳转失败,请稍后再试");
|
||||
} finally {
|
||||
setJumping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !jumping && setOpen(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>3q 未完成</DialogTitle>
|
||||
<DialogDescription>
|
||||
未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={jumping}>
|
||||
暂不
|
||||
</Button>
|
||||
<Button
|
||||
// 弹框经 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}
|
||||
>
|
||||
{jumping ? <Loader2 className="h-4 w-4 animate-spin" /> : "前往 3qfx"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务工作区布局:左列表 + 右 Outlet。
|
||||
* ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。
|
||||
@@ -305,6 +387,8 @@ export function TaskFxWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
<TaskJumpBar business={business} taskId={taskId} />
|
||||
{/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */}
|
||||
{business === "rwfx" && <RwfxSqReportGate taskId={taskId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user