Merge branch 'dhk-1' into 'master'
feat(qa-center): 问答中心左侧栏改造(0616 需求 2-5) See merge request knowledge-base-bj/deerflow-server!126
This commit is contained in:
commit
e3c1adb71a
61 files changed
+3465
-117
No files matched your search
@@ -84,6 +84,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalysSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
|
||||
|
||||
### Configuration
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=http://localhost:8003
|
||||
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Select } from "tdesign-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import {
|
||||
useAgentModuleConfig,
|
||||
useUpdateAgentModuleConfig,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { useTags } from "@/core/tags";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
/**
|
||||
* 模块(zzJC / zzXD)类型配置弹窗(需求 0616 #3/#4,admin)。
|
||||
*
|
||||
* 单选一个「类型」(agent 标签,TDesign 下拉框)→ 下方实时预览该类型下的智能体(名称+描述)→
|
||||
* 确认后写入后端(``/api/system-settings/agent-modules/{key}``,单选即存单元素列表),
|
||||
* 该模块左栏据此只展示选中类型的智能体。
|
||||
*/
|
||||
export function AgentModuleSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
moduleKey,
|
||||
moduleTitle,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
moduleKey: string;
|
||||
moduleTitle: string;
|
||||
}) {
|
||||
const { data: tags = [] } = useTags("agent");
|
||||
const { agents } = useAgents();
|
||||
const { data: config } = useAgentModuleConfig(moduleKey);
|
||||
const { mutate: save, isPending } = useUpdateAgentModuleConfig(moduleKey);
|
||||
|
||||
const [selectedTagId, setSelectedTagId] = useState<string | undefined>();
|
||||
// Radix Dialog(modal) 把 body 设为 pointer-events:none,TDesign 浮层默认挂到 body 上
|
||||
// 会点不动 —— 用 attach 把下拉浮层挂到弹窗内部容器(pointer-events 正常)。
|
||||
const selectWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 每次打开同步当前已保存配置(取首个,单选语义)。
|
||||
useEffect(() => {
|
||||
if (open) setSelectedTagId(config?.tag_ids?.[0]);
|
||||
}, [open, config?.tag_ids]);
|
||||
|
||||
const tagOptions = useMemo(
|
||||
() => tags.map((t) => ({ label: t.name, value: t.id })),
|
||||
[tags],
|
||||
);
|
||||
|
||||
// 选中类型下的智能体(含名称 + 描述)。
|
||||
const previewAgents = useMemo<Agent[]>(() => {
|
||||
if (!selectedTagId) return [];
|
||||
return agents.filter((agent) =>
|
||||
(agent.tags ?? []).some((t) => t.id === selectedTagId),
|
||||
);
|
||||
}, [agents, selectedTagId]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
save(selectedTagId ? [selectedTagId] : [], {
|
||||
onSuccess: () => {
|
||||
toast.success(`已更新「${moduleTitle}」展示的类型`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "保存失败,请重试"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[78vh] max-h-[88vh] min-h-[620px] flex-col gap-0 p-0 sm:max-w-[760px]">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-4">
|
||||
<DialogTitle>{moduleTitle} · 类型配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择该模块左栏展示的智能体类型(标签);下方预览该类型下的智能体。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-hidden px-6 py-5">
|
||||
<div className="shrink-0">
|
||||
<div className="text-muted-foreground/80 mb-2 text-sm font-semibold">
|
||||
选择类型
|
||||
</div>
|
||||
{tags.length === 0 ? (
|
||||
<p className="text-muted-foreground py-1 text-sm">
|
||||
暂无智能体标签,请先到「标签管理」创建。
|
||||
</p>
|
||||
) : (
|
||||
<div ref={selectWrapRef} className="relative">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedTagId}
|
||||
options={tagOptions}
|
||||
filterable
|
||||
clearable
|
||||
placeholder="搜索并选择一个类型"
|
||||
popupProps={{
|
||||
attach: () => selectWrapRef.current ?? document.body,
|
||||
}}
|
||||
onChange={(v) => setSelectedTagId((v as string) || undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="text-muted-foreground/80 mb-2 shrink-0 text-sm font-semibold">
|
||||
该类型下的智能体({previewAgents.length})
|
||||
</div>
|
||||
{!selectedTagId ? (
|
||||
<p className="text-muted-foreground py-1 text-sm">
|
||||
未选择类型时,左栏展示全部可见智能体。
|
||||
</p>
|
||||
) : previewAgents.length === 0 ? (
|
||||
<p className="text-muted-foreground py-1 text-sm">
|
||||
该类型下暂无智能体。
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 flex-1 pr-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{previewAgents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-start gap-3 rounded-xl border border-border/60 bg-card p-3"
|
||||
>
|
||||
<div className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-foreground">
|
||||
{desensitize(agent.name)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{agent.description?.trim()
|
||||
? desensitize(agent.description)
|
||||
: "暂无描述"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-border/60 px-6 py-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isPending}>
|
||||
{isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, ChevronDown, MessageSquare, Settings } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAgents } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { useAgentModuleConfig } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useThreads } from "@/core/threads/hooks";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
import {
|
||||
pathOfThread,
|
||||
sortThreadsPinnedFirst,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import type { Tag } from "@/core/tags";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 单个「按类型聚合」模块配置(需求 0616 #3 zzJC / #4 zzXD)。 */
|
||||
export interface AgentModuleConfig {
|
||||
/** 模块标识(路由 /page/strategy/qa/{key} 的 key)。 */
|
||||
key: string;
|
||||
/** 模块标题。 */
|
||||
title: string;
|
||||
/** 未配置类型时的默认落地智能体 id。 */
|
||||
fallbackAgentId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已知「按类型聚合」模块 —— 按 **moduleKey** 索引(路由 /page/strategy/qa/{key})。
|
||||
* zzJC / zzXD 布局完全一致,仅默认落地智能体不同。
|
||||
*/
|
||||
export const AGENT_MODULES: Record<string, AgentModuleConfig> = {
|
||||
zzjc: { key: "zzjc", title: "zzJC支持", fallbackAgentId: "6bf" },
|
||||
zzxd: {
|
||||
key: "zzxd",
|
||||
title: "zzXD应用",
|
||||
fallbackAgentId: "10d69686718349688e58a8790cb61024",
|
||||
},
|
||||
};
|
||||
|
||||
const UNTAGGED_KEY = "__untagged__";
|
||||
|
||||
interface AgentGroup {
|
||||
/** 分组键(tag id 或未分类常量)。 */
|
||||
key: string;
|
||||
/** 分组显示名。 */
|
||||
label: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 agent 标签把可见智能体分组(多标签智能体会出现在多个组)。
|
||||
*
|
||||
* - ``allowedTagIds`` 非空(admin 已配置该模块的类型):仅保留这些标签、按其配置顺序,
|
||||
* 空组(无智能体)不展示;不含「未分类」。
|
||||
* - ``allowedTagIds`` 为空(未配置):回退展示全部标签 + 「未分类」。
|
||||
*/
|
||||
function groupAgentsByTag(agents: Agent[], allowedTagIds: string[]): AgentGroup[] {
|
||||
if (allowedTagIds.length > 0) {
|
||||
const allowed = new Set(allowedTagIds);
|
||||
const byTag = new Map<string, { tag: Tag; agents: Agent[] }>();
|
||||
for (const agent of agents) {
|
||||
for (const tag of agent.tags ?? []) {
|
||||
if (!allowed.has(tag.id)) continue;
|
||||
const entry = byTag.get(tag.id) ?? { tag, agents: [] };
|
||||
entry.agents.push(agent);
|
||||
byTag.set(tag.id, entry);
|
||||
}
|
||||
}
|
||||
const groups: AgentGroup[] = [];
|
||||
for (const tagId of allowedTagIds) {
|
||||
const entry = byTag.get(tagId);
|
||||
if (entry && entry.agents.length > 0) {
|
||||
groups.push({ key: tagId, label: entry.tag.name, agents: entry.agents });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
const byTag = new Map<string, { tag: Tag; agents: Agent[] }>();
|
||||
const untagged: Agent[] = [];
|
||||
for (const agent of agents) {
|
||||
const tags = agent.tags ?? [];
|
||||
if (tags.length === 0) {
|
||||
untagged.push(agent);
|
||||
continue;
|
||||
}
|
||||
for (const tag of tags) {
|
||||
const entry = byTag.get(tag.id) ?? { tag, agents: [] };
|
||||
entry.agents.push(agent);
|
||||
byTag.set(tag.id, entry);
|
||||
}
|
||||
}
|
||||
const groups: AgentGroup[] = [...byTag.values()]
|
||||
.map((e) => ({ key: e.tag.id, label: e.tag.name, agents: e.agents }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "zh"));
|
||||
if (untagged.length > 0) {
|
||||
groups.push({ key: UNTAGGED_KEY, label: "未分类", agents: untagged });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「按类型聚合智能体 + 聊天记录」左栏(需求 0616 #3 zzJC / #4 zzXD)。
|
||||
*
|
||||
* - 上半:可见智能体按标签(类型)分组、可折叠;点击进入该智能体对话(保持当前路由
|
||||
* 命名空间,全量模式下为 /page/strategy)。
|
||||
* - 下半:聊天记录 —— 仅与本模块智能体(即左栏列出的智能体)的会话,按 metadata.agent_id 过滤。
|
||||
* - 宽度与通用问答左栏一致(``--sidebar-width``)。
|
||||
*/
|
||||
export function AgentModuleSidebar({
|
||||
moduleKey,
|
||||
activeAgentId,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
moduleKey: string;
|
||||
activeAgentId?: string;
|
||||
/** 传入则在左栏顶部显示设置齿轮(admin)。 */
|
||||
onOpenSettings?: () => void;
|
||||
}) {
|
||||
const routeBase = useRouteBase();
|
||||
const pathname = usePathname();
|
||||
const { agents } = useAgents();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { data: moduleConfig } = useAgentModuleConfig(moduleKey);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const allowedTagIds = useMemo(
|
||||
() => moduleConfig?.tag_ids ?? [],
|
||||
[moduleConfig?.tag_ids],
|
||||
);
|
||||
const groups = useMemo(
|
||||
() => groupAgentsByTag(agents, allowedTagIds),
|
||||
[agents, allowedTagIds],
|
||||
);
|
||||
// 本模块智能体集合 = 左栏实际展示的智能体(聊天记录据此过滤,仅本模块相关会话)。
|
||||
const moduleAgentIds = useMemo(
|
||||
() => new Set(groups.flatMap((g) => g.agents.map((a) => a.id))),
|
||||
[groups],
|
||||
);
|
||||
|
||||
// 聊天记录:仅与本模块智能体的会话(按 metadata.agent_id 过滤),置顶优先。
|
||||
const moduleThreads = useMemo(
|
||||
() =>
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter((thread: AgentThread) => {
|
||||
const agentId = thread.metadata?.agent_id;
|
||||
return typeof agentId === "string" && moduleAgentIds.has(agentId);
|
||||
}),
|
||||
),
|
||||
[threads, moduleAgentIds],
|
||||
);
|
||||
|
||||
const toggleGroup = (key: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
|
||||
{onOpenSettings && (
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
|
||||
<span className="text-xs font-semibold text-foreground/80">智能体</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
title="模块设置"
|
||||
aria-label="模块设置"
|
||||
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上半:按类型分组的智能体 */}
|
||||
<div className="flex min-h-0 flex-[3] flex-col overflow-y-auto px-2 py-3">
|
||||
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
智能体类型
|
||||
</h2>
|
||||
{groups.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无智能体</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groups.map((group) => {
|
||||
const isCollapsed = collapsed.has(group.key);
|
||||
return (
|
||||
<div key={group.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
className="flex w-full items-center justify-between gap-1 rounded-lg px-2 py-1.5 text-xs font-semibold text-foreground/80 hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span className="truncate">{group.label}</span>
|
||||
<span className="text-muted-foreground/60 text-[10px] font-normal tabular-nums">
|
||||
{group.agents.length}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-200",
|
||||
isCollapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div className="mt-0.5 flex flex-col gap-0.5 pl-1">
|
||||
{group.agents.map((agent) => {
|
||||
const href = `${routeBase}/agents/${agent.id}/chats/new`;
|
||||
const isActive = agent.id === activeAgentId;
|
||||
return (
|
||||
<Link
|
||||
key={`${group.key}:${agent.id}`}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
|
||||
isActive &&
|
||||
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">{desensitize(agent.name)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下半:聊天记录(仅本模块智能体会话) */}
|
||||
<div className="flex min-h-0 flex-[2] flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
|
||||
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
聊天记录
|
||||
</h2>
|
||||
{moduleThreads.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无聊天记录</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{moduleThreads.map((thread) => {
|
||||
const href = pathOfThread(thread, undefined, routeBase);
|
||||
const isActive = href === pathname;
|
||||
return (
|
||||
<Link
|
||||
key={thread.thread_id}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
|
||||
isActive &&
|
||||
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, "未命名对话"))}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -20,9 +20,10 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
|
||||
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentSelectorProps {
|
||||
@@ -51,12 +52,21 @@ export function AgentSelector({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const { agents, isLoading } = useAgentSelector();
|
||||
// 弹窗列表只展示管理弹窗里配置的 agent(agents);但「已选中 agent」的名称解析要更宽——
|
||||
// 侧边栏「常用智能体」可以选中未配置进管理弹窗的 agent(如用户自己的智能体),
|
||||
// 这些 agent 也要能在胶囊上显示名称,故额外用「我的 / 广场」列表兜底解析(缓存与侧边栏共享)。
|
||||
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
|
||||
|
||||
const selectedAgent = useMemo<Agent | null>(() => {
|
||||
if (!selectedAgentId) return null;
|
||||
return agents.find((a) => a.id === selectedAgentId) ?? null;
|
||||
}, [agents, selectedAgentId]);
|
||||
return (
|
||||
[...agents, ...mineAgents, ...squareAgents].find(
|
||||
(a) => a.id === selectedAgentId,
|
||||
) ?? null
|
||||
);
|
||||
}, [agents, mineAgents, squareAgents, selectedAgentId]);
|
||||
|
||||
const handleSelect = (agent: Agent) => {
|
||||
onAgentChange(agent.id);
|
||||
@@ -68,7 +78,7 @@ export function AgentSelector({
|
||||
onAgentChange(null);
|
||||
};
|
||||
|
||||
const triggerLabel = selectedAgent?.name ?? "选择 Agent";
|
||||
const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -125,7 +135,7 @@ export function AgentSelector({
|
||||
{agents.map((agent) => (
|
||||
<CommandItem
|
||||
key={agent.id}
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
value={`${desensitize(agent.name)} ${desensitize(agent.description ?? "")}`}
|
||||
onSelect={() => handleSelect(agent)}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-start gap-2 overflow-hidden",
|
||||
@@ -134,10 +144,10 @@ export function AgentSelector({
|
||||
>
|
||||
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
<span className="truncate text-sm">{desensitize(agent.name)}</span>
|
||||
{agent.description && (
|
||||
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
|
||||
{agent.description}
|
||||
{desensitize(agent.description)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -122,6 +122,7 @@ export function ArtifactFileDetail({
|
||||
threadId,
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
}: {
|
||||
className?: string;
|
||||
filepath: string;
|
||||
@@ -134,6 +135,12 @@ export function ArtifactFileDetail({
|
||||
* 默认 false —— 主聊天页等其它调用方行为不变。
|
||||
*/
|
||||
allowVirtualMarkdownEdit?: boolean;
|
||||
/**
|
||||
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
|
||||
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
|
||||
* 默认 false,沿用全局脱敏。
|
||||
*/
|
||||
disableDesensitize?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
@@ -802,7 +809,7 @@ export function ArtifactFileDetail({
|
||||
language === "html" ||
|
||||
language === "mermaid") && (
|
||||
<ArtifactFilePreview
|
||||
content={desensitize(displayContent ?? "")}
|
||||
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
|
||||
language={language ?? "text"}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
@@ -815,7 +822,7 @@ export function ArtifactFileDetail({
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={
|
||||
canEditArtifact
|
||||
canEditArtifact || disableDesensitize
|
||||
? (displayContent ?? "")
|
||||
: desensitize(displayContent ?? "")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通用问答(全量版 / full layout)新建对话左侧栏 —— 按「样式修改.png」参考图重画。
|
||||
*
|
||||
* 这是**全量版专属**的独立组件:因为全量版是新功能、新需求,若继续往
|
||||
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
|
||||
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
|
||||
*
|
||||
* 三块结构(自上而下):
|
||||
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
|
||||
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
|
||||
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
|
||||
* 重命名/置顶/收藏/分享/导出/删除)、「更多最近对话」入口,底部品牌信息。
|
||||
*/
|
||||
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
exportThreadAsJSON,
|
||||
exportThreadAsMarkdown,
|
||||
} from "@/core/threads/export";
|
||||
import {
|
||||
useDeleteThread,
|
||||
useRenameThread,
|
||||
useThreads,
|
||||
useToggleThreadFavorite,
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
pathOfThread,
|
||||
sortThreadsPinnedFirst,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
|
||||
const NEW_CHAT_PATH = "/page/workspace/chats/new";
|
||||
/** 「更多」常用智能体跳转目标:智能体管理页。 */
|
||||
const MORE_AGENTS_HREF = "/page/workspace/agents";
|
||||
/** 「更多最近对话」跳转目标。 */
|
||||
const MORE_CHATS_HREF = "/page/workspace/chats";
|
||||
/** 常用智能体最多展示个数。 */
|
||||
const MAX_FREQUENT = 4;
|
||||
|
||||
// 智能体图标统一用 Bot 机器人图标;底色按 id 稳定散列到一组主题色,复刻参考图的彩色方块。
|
||||
const AGENT_COLORS = [
|
||||
{ from: "#fb7185", to: "#ef4444" },
|
||||
{ from: "#60a5fa", to: "#3b82f6" },
|
||||
{ from: "#fbbf24", to: "#f59e0b" },
|
||||
{ from: "#34d399", to: "#10b981" },
|
||||
{ from: "#a78bfa", to: "#8b5cf6" },
|
||||
] as const;
|
||||
|
||||
function agentColor(id: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return AGENT_COLORS[hash % AGENT_COLORS.length]!;
|
||||
}
|
||||
|
||||
/** 折叠区块标题(带 chevron 与可选计数徽标)。 */
|
||||
function SectionLabel({
|
||||
label,
|
||||
count,
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
count?: number;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{label}</span>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-200",
|
||||
collapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralQaSidebar() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── 区块折叠状态(持久化,键独立于旧组件,避免互相影响)──
|
||||
const [agentsCollapsed, setAgentsCollapsed] = useState(
|
||||
() => localStorage.getItem("gqa-sidebar:agents-collapsed") === "true",
|
||||
);
|
||||
const [chatsCollapsed, setChatsCollapsed] = useState(
|
||||
() => localStorage.getItem("gqa-sidebar:chats-collapsed") === "true",
|
||||
);
|
||||
const toggleAgents = () =>
|
||||
setAgentsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
||||
return !v;
|
||||
});
|
||||
const toggleChats = () =>
|
||||
setChatsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
||||
return !v;
|
||||
});
|
||||
|
||||
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
|
||||
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||
const frequent = useMemo<Agent[]>(() => {
|
||||
const out: Agent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (agent: Agent | undefined) => {
|
||||
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
|
||||
seen.add(agent.id);
|
||||
out.push(agent);
|
||||
};
|
||||
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
|
||||
mine.forEach(add);
|
||||
square.forEach(add);
|
||||
return out;
|
||||
}, [mine, square]);
|
||||
|
||||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||
const searchParams = useSearchParams();
|
||||
const activeAgentId = searchParams.get("agent");
|
||||
|
||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||
const handleSelectAgent = useCallback(
|
||||
(agentId: string) => {
|
||||
navigate(`/page/workspace/chats/new?agent=${encodeURIComponent(agentId)}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// ── 最近的对话取数 + 搜索 ──
|
||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
|
||||
thread_id: string;
|
||||
agent_id?: string;
|
||||
}>();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const { mutate: renameThread } = useRenameThread();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
[threads],
|
||||
);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = q
|
||||
? visibleThreads.filter((thread) =>
|
||||
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
|
||||
)
|
||||
: visibleThreads;
|
||||
// 未搜索时与旧版一致:最多展示 10 条
|
||||
return q ? list : list.slice(0, 10);
|
||||
}, [visibleThreads, query, t.pages.newChat]);
|
||||
|
||||
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
||||
event.preventDefault();
|
||||
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
|
||||
};
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(threadId: string) => {
|
||||
deleteThread({ threadId });
|
||||
if (threadId === threadIdFromPath) {
|
||||
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
|
||||
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
}
|
||||
},
|
||||
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
||||
);
|
||||
|
||||
const handleRenameClick = useCallback((threadId: string, currentTitle: string) => {
|
||||
setRenameThreadId(threadId);
|
||||
setRenameValue(currentTitle);
|
||||
setRenameDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRenameSubmit = useCallback(() => {
|
||||
if (renameThreadId && renameValue.trim()) {
|
||||
renameThread({ threadId: renameThreadId, title: renameValue.trim() });
|
||||
setRenameDialogOpen(false);
|
||||
setRenameThreadId(null);
|
||||
setRenameValue("");
|
||||
}
|
||||
}, [renameThread, renameThreadId, renameValue]);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async (thread: AgentThread, format: "markdown" | "json") => {
|
||||
try {
|
||||
const apiClient = getAPIClient();
|
||||
const state = await apiClient.threads.getState<AgentThreadState>(
|
||||
thread.thread_id,
|
||||
);
|
||||
const messages = state.values?.messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
toast.error(t.conversation.noMessages);
|
||||
return;
|
||||
}
|
||||
if (format === "markdown") exportThreadAsMarkdown(thread, messages);
|
||||
else exportThreadAsJSON(thread, messages);
|
||||
toast.success(t.common.exportSuccess);
|
||||
} catch {
|
||||
toast.error("Failed to export conversation");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const isStatic = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-white">
|
||||
{/* ── 顶部:新对话胶囊 + 收起按钮 ── */}
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||
{/* 新对话按钮:完全自定义样式(不复用 Button variant)。borderColor / color 走
|
||||
inline style,避免被全局 border-color(--border)覆盖;无阴影。浅主色底 + 主色描边
|
||||
+ 5px 圆角,全部走 --primary token,自动适配红/蓝/深蓝主题。 */}
|
||||
<Link
|
||||
href={NEW_CHAT_PATH}
|
||||
onClick={handleNewChatClick}
|
||||
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
|
||||
className={cn(
|
||||
"flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
|
||||
"border border-solid",
|
||||
"bg-[color-mix(in_srgb,var(--primary)_12%,white)] hover:bg-[color-mix(in_srgb,var(--primary)_20%,white)]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4 stroke-[2.5]" />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
<SidebarCollapseButton className="size-9 shrink-0 rounded-full border border-border bg-card text-muted-foreground shadow-sm hover:bg-accent" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{/* ── 常用智能体 ── */}
|
||||
{frequent.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<SectionLabel
|
||||
label="常用智能体"
|
||||
collapsed={agentsCollapsed}
|
||||
onToggle={toggleAgents}
|
||||
/>
|
||||
{!agentsCollapsed && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{frequent.map((agent) => {
|
||||
const isActive = activeAgentId === agent.id;
|
||||
const { from, to } = agentColor(agent.id);
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectAgent(agent.id)}
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 text-left transition-colors",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-transparent hover:border-border hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
|
||||
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
|
||||
>
|
||||
<Bot className="size-[18px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[13px] font-semibold",
|
||||
isActive ? "text-primary" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
|
||||
{agent.description ? desensitize(agent.description) : "智能体助手"}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href={MORE_AGENTS_HREF}
|
||||
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<span>{t.common.more}</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 最近的对话 ── */}
|
||||
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
|
||||
<SectionLabel
|
||||
label={t.sidebar.recentChats}
|
||||
count={visibleThreads.length}
|
||||
collapsed={chatsCollapsed}
|
||||
onToggle={toggleChats}
|
||||
/>
|
||||
{!chatsCollapsed && (
|
||||
<div className="mt-1.5">
|
||||
{/* 历史会话搜索框 */}
|
||||
<div className="relative mb-1.5 px-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索历史会话…"
|
||||
className="h-8 rounded-lg border-border bg-card pl-8 text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{filteredThreads.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
|
||||
{query ? "没有匹配的会话" : t.chats.noChats}
|
||||
</div>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
<div
|
||||
key={thread.thread_id}
|
||||
className="group/item relative flex items-center"
|
||||
>
|
||||
<Link
|
||||
href={pathOfThread(thread)}
|
||||
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
|
||||
? "border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||
: "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",
|
||||
)}
|
||||
/>
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
</Link>
|
||||
{!isStatic && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 flex size-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-background group-hover/item:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">{t.common.more}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 rounded-lg" side="right" align="start">
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleRenameClick(
|
||||
thread.thread_id,
|
||||
titleOfThread(thread, t.pages.newChat),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>{t.common.rename}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => togglePin(thread.thread_id, !pinned)}>
|
||||
{pinned ? (
|
||||
<PinOff className="text-muted-foreground" />
|
||||
) : (
|
||||
<Pin className="text-muted-foreground" />
|
||||
)}
|
||||
<span>{pinned ? t.common.unpin : t.common.pin}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => toggleFavorite(thread.thread_id, !favorited)}
|
||||
>
|
||||
<Star
|
||||
className={
|
||||
favorited
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
<span>{favorited ? t.common.unfavorite : t.common.favorite}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShareThreadId(thread.thread_id)}>
|
||||
<Share2 className="text-muted-foreground" />
|
||||
<span>{t.common.share}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Download className="text-muted-foreground" />
|
||||
<span>{t.common.export}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={() => handleExport(thread, "markdown")}>
|
||||
<FileText className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsMarkdown}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => handleExport(thread, "json")}>
|
||||
<FileJson className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsJSON}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => handleDelete(thread.thread_id)}>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{!query && (
|
||||
<Link
|
||||
href={MORE_CHATS_HREF}
|
||||
className="mt-0.5 flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<span>更多最近对话</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重命名弹窗 */}
|
||||
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.common.rename}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder={t.common.rename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isIMEComposing(e)) {
|
||||
e.preventDefault();
|
||||
handleRenameSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleRenameSubmit}>{t.common.save}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 分享弹窗 */}
|
||||
<ShareConversationDialog
|
||||
threadId={shareThreadId}
|
||||
open={shareThreadId !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setShareThreadId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { FrequentAgentsList } from "./frequent-agents-list";
|
||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
@@ -30,7 +30,8 @@ export function WorkspaceSidebar({
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;其余 workspace 页保持原样。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
@@ -58,13 +59,16 @@ export function WorkspaceSidebar({
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
{isGeneralQa && <FrequentAgentsList />}
|
||||
<RecentChatList
|
||||
compact
|
||||
limit={isGeneralQa ? 10 : undefined}
|
||||
moreHref={isGeneralQa ? "/page/workspace/chats" : undefined}
|
||||
/>
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 外部系统跳转登录时的「任务深链」:登录 URL 带 ``taskId`` + ``goPath`` 时,
|
||||
* 拉取任务详情并据 goPath 把用户直接送到对应的智能体问答 / 多智能体会商页。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalysSearch/cop-task-detail?taskId=``,
|
||||
* 请求头 ``Authorization: Bearer admin``(由 service 拦截器统一注入)。
|
||||
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent)整包存到 sessionStorage。
|
||||
* 3. 按 goPath 解析出业务码 + 模式:
|
||||
* - ``3qfx`` / ``rwfx`` / ``xdfx`` → 单智能体问答(用「配置管理」里该业务配置的智能体)
|
||||
* - ``3qfx-A`` / ``rwfx-A`` / ``xdfx-A`` → 多智能体会商(用该业务配置的业务链条)
|
||||
* 携带 taskName / taskContent 作为开场信息。
|
||||
*
|
||||
* 业务码映射与「配置管理」(`/api/business-mapping`) 一致:
|
||||
* 3Q=三情分析(3QFX)、6BF=任务分析(任务FX)、7BF=行动分析(XDFX)。
|
||||
*/
|
||||
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||
|
||||
/** consumer 接口返回的任务详情(data 字段)。 */
|
||||
export interface CopTaskDetail {
|
||||
id: string | number | null;
|
||||
overview: string;
|
||||
taskDirection: string;
|
||||
taskName: string;
|
||||
taskContent: string;
|
||||
}
|
||||
|
||||
/** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */
|
||||
export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail";
|
||||
|
||||
/** goPath 基础码 → 业务码(配置管理 businessCode)。 */
|
||||
const GOPATH_BUSINESS_CODE: Record<string, string> = {
|
||||
"3qfx": "3Q", // 三情分析
|
||||
rwfx: "6BF", // 任务分析(任务FX)
|
||||
xdfx: "7BF", // 行动分析(XDFX)
|
||||
};
|
||||
|
||||
export interface ParsedGoPath {
|
||||
businessCode: string;
|
||||
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
|
||||
roundtable: boolean;
|
||||
}
|
||||
|
||||
/** 解析 goPath(大小写不敏感);``-A`` 后缀表示多智能体会商。无法识别返回 null。 */
|
||||
export function parseGoPath(goPath: string): ParsedGoPath | null {
|
||||
const raw = goPath.trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
const roundtable = raw.endsWith("-a");
|
||||
const base = roundtable ? raw.slice(0, -2) : raw;
|
||||
const businessCode = GOPATH_BUSINESS_CODE[base];
|
||||
if (!businessCode) return null;
|
||||
return { businessCode, roundtable };
|
||||
}
|
||||
|
||||
function consumerBaseUrl(): string {
|
||||
const value = import.meta.env.VITE_AIPI_CONSUMER_URL;
|
||||
return typeof value === "string" ? value.replace(/\/+$/, "") : "";
|
||||
}
|
||||
|
||||
/** 拉取任务详情;缺少 consumer 地址 / 返回不合法时返回 null。 */
|
||||
export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
|
||||
const base = consumerBaseUrl();
|
||||
if (!base || !taskId) return null;
|
||||
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
|
||||
const body = await requestCrudService.get(`${base}/taskAnalysSearch/cop-task-detail`, { taskId });
|
||||
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
return {
|
||||
id: (d.id as string | number | null) ?? null,
|
||||
overview: typeof d.overview === "string" ? d.overview : "",
|
||||
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
|
||||
taskName: typeof d.taskName === "string" ? d.taskName : "",
|
||||
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
|
||||
};
|
||||
}
|
||||
|
||||
/** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */
|
||||
export function persistCopTaskDetail(detail: CopTaskDetail): void {
|
||||
try {
|
||||
sessionStorage.setItem(COP_TASK_DETAIL_STORAGE_KEY, JSON.stringify(detail));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取暂存的任务详情。 */
|
||||
export function readCopTaskDetail(): CopTaskDetail | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(COP_TASK_DETAIL_STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as CopTaskDetail) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TaskDeeplinkTarget {
|
||||
path: string;
|
||||
/** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 用 taskName / taskContent 拼出开场信息文本。 */
|
||||
function buildOpeningText(detail: CopTaskDetail): string {
|
||||
return [
|
||||
detail.taskName ? `任务名称:${detail.taskName}` : "",
|
||||
detail.taskContent ? `任务内容:${detail.taskContent}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 据 goPath + 任务详情解析跳转目标。
|
||||
* - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条)的 state。
|
||||
* - 单智能体问答:解析「配置管理」里该业务配置的智能体;把开场信息写入
|
||||
* ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。
|
||||
* 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。
|
||||
*/
|
||||
export async function resolveTaskDeeplinkTarget(
|
||||
goPath: string,
|
||||
detail: CopTaskDetail,
|
||||
): Promise<TaskDeeplinkTarget | null> {
|
||||
const parsed = parseGoPath(goPath);
|
||||
if (!parsed) return null;
|
||||
|
||||
if (parsed.roundtable) {
|
||||
return {
|
||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||
state: {
|
||||
roundtablePrefill: {
|
||||
taskTitle: detail.taskName,
|
||||
taskGoal: detail.taskContent,
|
||||
businessCode: parsed.businessCode,
|
||||
autoStart: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const mappings = await listBusinessMappings();
|
||||
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
|
||||
} catch {
|
||||
agentId = null;
|
||||
}
|
||||
if (!agentId) return null;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"pendingAgentMessage",
|
||||
JSON.stringify({ agentId, text: buildOpeningText(detail) }),
|
||||
);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return { path: `/page/strategy/agents/${agentId}/chats/new` };
|
||||
}
|
||||
@@ -67,9 +67,9 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
id: "qa-zzjc",
|
||||
label: "zzJC支持",
|
||||
icon: Bot,
|
||||
path: "/page/strategy/agents/6bf/chats/new",
|
||||
path: "/page/strategy/qa/zzjc",
|
||||
},
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -247,6 +247,45 @@ export async function updateCitationDisplaySettings(
|
||||
return (await response.json()) as CitationDisplaySettings;
|
||||
}
|
||||
|
||||
/** 「按类型聚合」模块(zzJC / zzXD)选中的智能体标签配置(需求 0616 #3/#4)。 */
|
||||
export interface AgentModuleConfig {
|
||||
key: string;
|
||||
tag_ids: string[];
|
||||
}
|
||||
|
||||
export async function loadAgentModuleConfig(
|
||||
key: string,
|
||||
): Promise<AgentModuleConfig> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/agent-modules/${encodeURIComponent(key)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load agent module config: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as AgentModuleConfig;
|
||||
}
|
||||
|
||||
export async function updateAgentModuleConfig(
|
||||
key: string,
|
||||
tagIds: string[],
|
||||
): Promise<AgentModuleConfig> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/agent-modules/${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag_ids: tagIds }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update agent module config" }));
|
||||
throw new Error(detail.detail ?? "Failed to update agent module config");
|
||||
}
|
||||
return (await response.json()) as AgentModuleConfig;
|
||||
}
|
||||
|
||||
export interface LogSystemSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
|
||||
import {
|
||||
loadAgentModuleConfig,
|
||||
loadAppearanceSettings,
|
||||
loadCitationDisplaySettings,
|
||||
loadLeaderboardSettings,
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
loadPublishSquares,
|
||||
updateAgentModuleConfig,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updateLeaderboardSettings,
|
||||
@@ -165,6 +167,29 @@ export function useLayoutMode(): [
|
||||
return [mode, setMode];
|
||||
}
|
||||
|
||||
/** 「按类型聚合」模块(zzJC / zzXD)选中标签配置(需求 0616 #3/#4)。 */
|
||||
const agentModuleKey = (key: string) =>
|
||||
["systemSettings", "agentModule", key] as const;
|
||||
|
||||
export function useAgentModuleConfig(key: string) {
|
||||
return useQuery({
|
||||
queryKey: agentModuleKey(key),
|
||||
queryFn: () => loadAgentModuleConfig(key),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAgentModuleConfig(key: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (tagIds: string[]) => updateAgentModuleConfig(key, tagIds),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(agentModuleKey(key), data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCitationDisplaySettings() {
|
||||
return useQuery({
|
||||
queryKey: CITATION_DISPLAY_KEY,
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* value: 脱敏后替换词
|
||||
*/
|
||||
export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
方案: "FA",
|
||||
设计: "SJ",
|
||||
任务: "RW",
|
||||
态势: "TS",
|
||||
滨海作战体系: "第一张网",
|
||||
滨海作战部队: "第一张网",
|
||||
@@ -72,6 +75,26 @@ export function desensitize(text: string): string {
|
||||
return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度脱敏:递归遍历对象 / 数组,对其中所有字符串值做 {@link desensitize},
|
||||
* 其余类型原样保留。返回**新值**(不修改入参),用于对结构化数据(如大屏 report-json
|
||||
* 解析出的对象)整体脱敏后再渲染。
|
||||
*
|
||||
* 注意:仅用于「展示」路径——不要用它改写要回传后端 / 落库的数据。
|
||||
*/
|
||||
export function desensitizeDeep<T>(value: T): T {
|
||||
if (typeof value === "string") return desensitize(value) as unknown as T;
|
||||
if (Array.isArray(value)) return value.map((v) => desensitizeDeep(v)) as unknown as T;
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = desensitizeDeep(v);
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时动态追加脱敏词,追加后自动重编译正则。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
/** 课题研究模板:一组写作表单预设(需求 0616 #5)。 */
|
||||
export interface ResearchTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
/** 引用的文章类型 key(对应 ArticleType.key);null = 不设。 */
|
||||
article_type_key: string | null;
|
||||
outline: string | null;
|
||||
material_source: 'general' | 'notebook';
|
||||
word_count: number;
|
||||
audience: string | null;
|
||||
strict_mode: boolean;
|
||||
topic: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateResearchTemplateRequest {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
article_type_key?: string | null;
|
||||
outline?: string | null;
|
||||
material_source?: 'general' | 'notebook';
|
||||
word_count?: number;
|
||||
audience?: string | null;
|
||||
strict_mode?: boolean;
|
||||
topic?: string | null;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export type UpdateResearchTemplateRequest = Partial<CreateResearchTemplateRequest>;
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/research-templates`;
|
||||
|
||||
export async function listResearchTemplates(): Promise<ResearchTemplate[]> {
|
||||
const resp = await apiFetch(BASE());
|
||||
if (!resp.ok) throw new Error(`获取研究模板失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function createResearchTemplate(
|
||||
data: CreateResearchTemplateRequest,
|
||||
): Promise<ResearchTemplate> {
|
||||
const resp = await apiFetch(BASE(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`创建研究模板失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function updateResearchTemplate(
|
||||
id: string,
|
||||
data: UpdateResearchTemplateRequest,
|
||||
): Promise<ResearchTemplate> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (resp.status === 404) throw new Error(`研究模板不存在`);
|
||||
if (!resp.ok) throw new Error(`更新研究模板失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function deleteResearchTemplate(id: string): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
|
||||
if (resp.status === 404) throw new Error(`研究模板不存在`);
|
||||
if (!resp.ok) throw new Error(`删除研究模板失败: ${resp.status}`);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { PenLine, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -55,8 +55,6 @@ export function AIWritingForm({
|
||||
onSubmit,
|
||||
disabled,
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: AIWritingFormProps) {
|
||||
const {
|
||||
@@ -68,13 +66,12 @@ export function AIWritingForm({
|
||||
wordCountInput, setWordCountInput,
|
||||
strictMode, setStrictMode,
|
||||
materialSource, setMaterialSource,
|
||||
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
|
||||
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
|
||||
selectedNotebookId, setSelectedNotebookId,
|
||||
selectedDocumentIds, setSelectedDocumentIds,
|
||||
modelName, setModelName,
|
||||
} = useWritingForm();
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(
|
||||
initialNotebookId ?? null,
|
||||
);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
||||
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
|
||||
@@ -146,8 +143,10 @@ export function AIWritingForm({
|
||||
}
|
||||
|
||||
function toggleDocument(docId: number) {
|
||||
setSelectedDocumentIds((prev) =>
|
||||
prev.includes(docId) ? prev.filter((id) => id !== docId) : [...prev, docId],
|
||||
setSelectedDocumentIds(
|
||||
selectedDocumentIds.includes(docId)
|
||||
? selectedDocumentIds.filter((id) => id !== docId)
|
||||
: [...selectedDocumentIds, docId],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, FileText, LayoutGrid, Loader2, Plus, Settings2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { createSearchSpace } from "@/core/studio/api";
|
||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useResearchTemplates } from "../../hooks/useResearchTemplates";
|
||||
import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
|
||||
import { useWritingForm } from "../../contexts/WritingFormContext";
|
||||
import { useAIWriting } from "../../contexts/AIWritingContext";
|
||||
|
||||
/**
|
||||
* 课题研究左栏(需求 0616 #5)。
|
||||
* - 上半「研究模板」= 研究模板实体(``useResearchTemplates``);点击一键预填整张写作表单
|
||||
* (文章类型 / 大纲 / 搜索来源 / 字数 / 受众 / 严格模式 / 主题)并展开配置。顶部「配置」
|
||||
* 入口进入模板配置页维护这些模板。
|
||||
* - 下半「笔记本」= 我的空间(``useStudioNotebooks``);点击在右侧内嵌打开该笔记本页。
|
||||
*/
|
||||
export function ResearchSidebar({
|
||||
viewingNotebookId,
|
||||
onOpenNotebook,
|
||||
onOpenAllSpaces,
|
||||
}: {
|
||||
/** 当前在右侧查看的笔记本 id(高亮用)。 */
|
||||
viewingNotebookId?: number | null;
|
||||
/** 点击笔记本 → 在右侧内嵌打开该笔记本页(替换右侧内容)。 */
|
||||
onOpenNotebook: (notebookId: number) => void;
|
||||
/** 点击「所有空间」→ 在右侧内嵌打开空间列表页。 */
|
||||
onOpenAllSpaces: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { templates, refresh } = useResearchTemplates();
|
||||
const { startWriting } = useAIWriting();
|
||||
const {
|
||||
notebooks,
|
||||
isLoading: notebooksLoading,
|
||||
isError: notebooksError,
|
||||
} = useStudioNotebooks();
|
||||
const {
|
||||
setIntent,
|
||||
setArticleType,
|
||||
setOutlineText,
|
||||
setShowOutline,
|
||||
setMaterialSource,
|
||||
setWordCountInput,
|
||||
setAudience,
|
||||
setStrictMode,
|
||||
setFormExpanded,
|
||||
} = useWritingForm();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 新建空间弹窗(与 workspace-nav-studio 同款)。
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setCreating(true);
|
||||
setCreateError("");
|
||||
try {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
const token = queryClient.getQueryData<string>(["studio", "token", email]);
|
||||
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
|
||||
await createSearchSpace(trimmed, token);
|
||||
setCreateOpen(false);
|
||||
setName("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : "创建失败");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击研究模板 → ①回填整张写作表单(保持写作态左栏显示与运行配置一致)
|
||||
// ②直接以模板预设进入 AI 写作流程(startWriting)。
|
||||
const handleTemplateClick = (tpl: ResearchTemplate) => {
|
||||
const intent = tpl.topic?.trim() ?? "";
|
||||
const articleType = tpl.article_type_key || "analysis";
|
||||
const outline = tpl.outline?.trim() || "";
|
||||
const audience = tpl.audience?.trim() || "普通读者";
|
||||
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
|
||||
const writingMode = tpl.strict_mode ? "strict" : "loose";
|
||||
|
||||
// 回填表单上下文(写作态左栏据此展示)。
|
||||
if (intent) setIntent(intent);
|
||||
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
|
||||
if (outline) {
|
||||
setOutlineText(outline);
|
||||
setShowOutline(true);
|
||||
}
|
||||
setMaterialSource(tpl.material_source);
|
||||
setWordCountInput(String(wordCount));
|
||||
if (tpl.audience?.trim()) setAudience(audience);
|
||||
setStrictMode(tpl.strict_mode);
|
||||
setFormExpanded(true);
|
||||
|
||||
// 无主题/意图无法发起写作 → 仅回填表单,由用户补充后手动开始。
|
||||
if (!intent) return;
|
||||
void startWriting({
|
||||
userIntent: intent,
|
||||
userOutline: outline || undefined,
|
||||
articleType,
|
||||
targetAudience: audience,
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: 1,
|
||||
writingMode,
|
||||
materialSource: tpl.material_source,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-64 shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
|
||||
{/* 上半:研究模板 */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
|
||||
<div className="px-2 mb-1.5 flex items-center justify-between">
|
||||
<h2 className="text-muted-foreground/70 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
研究模板
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/canvas/research-templates")}
|
||||
title="配置研究模板"
|
||||
aria-label="配置研究模板"
|
||||
className="text-muted-foreground/70 hover:text-foreground rounded p-0.5 transition-colors"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{templates.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">
|
||||
暂无研究模板,点右上角配置
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{templates.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplateClick(tpl)}
|
||||
title={tpl.description ?? tpl.name}
|
||||
className="flex items-start gap-2 rounded-lg px-2.5 py-1.5 text-left text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
|
||||
>
|
||||
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-medium">{tpl.name}</span>
|
||||
{tpl.description && (
|
||||
<span className="text-muted-foreground/70 line-clamp-1 block text-[11px]">
|
||||
{tpl.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下半:笔记本(我的空间) */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
|
||||
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
笔记本
|
||||
</h2>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* 新建空间 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setName("");
|
||||
setCreateError("");
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span>新建空间</span>
|
||||
</button>
|
||||
|
||||
{notebooksLoading ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">加载中…</p>
|
||||
) : notebooksError ? (
|
||||
<p className="px-2 py-1.5 text-xs text-amber-600">
|
||||
笔记本服务暂不可用(检查空间服务地址 / 账号)
|
||||
</p>
|
||||
) : notebooks.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无笔记本</p>
|
||||
) : (
|
||||
notebooks.map((nb) => {
|
||||
const active = viewingNotebookId === nb.id;
|
||||
return (
|
||||
<button
|
||||
key={nb.id}
|
||||
type="button"
|
||||
onClick={() => onOpenNotebook(nb.id)}
|
||||
title={nb.description || nb.name}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">{nb.name}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 所有空间 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAllSpaces}
|
||||
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
|
||||
>
|
||||
<LayoutGrid className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span>所有空间</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新建空间弹窗 */}
|
||||
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCreate();
|
||||
}}
|
||||
placeholder="空间名称"
|
||||
disabled={creating}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={creating}
|
||||
onClick={() => setCreateOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || creating}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,12 @@ export interface WritingFormValue {
|
||||
setStrictMode: (v: boolean) => void;
|
||||
materialSource: 'general' | 'notebook';
|
||||
setMaterialSource: (v: 'general' | 'notebook') => void;
|
||||
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
|
||||
selectedNotebookId: number | null;
|
||||
setSelectedNotebookId: (v: number | null) => void;
|
||||
/** 选中笔记本下的文档 id 列表(缩小检索范围)。 */
|
||||
selectedDocumentIds: number[];
|
||||
setSelectedDocumentIds: (v: number[]) => void;
|
||||
modelName: string;
|
||||
setModelName: (v: string) => void;
|
||||
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
|
||||
@@ -56,7 +62,17 @@ export function useWritingFormOptional(): WritingFormValue | null {
|
||||
return useContext(WritingFormContext);
|
||||
}
|
||||
|
||||
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'general' | 'notebook' }) {
|
||||
export function WritingFormProvider({
|
||||
children,
|
||||
initialMaterialSource,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialMaterialSource?: 'general' | 'notebook';
|
||||
initialNotebookId?: number | null;
|
||||
initialDocumentIds?: number[];
|
||||
}) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [formExpanded, setFormExpanded] = useState(false);
|
||||
const [showOutline, setShowOutline] = useState(true);
|
||||
@@ -66,6 +82,8 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
||||
const [modelName, setModelName] = useState('');
|
||||
|
||||
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
|
||||
@@ -100,10 +118,12 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
|
||||
audience, setAudience,
|
||||
strictMode, setStrictMode,
|
||||
materialSource, setMaterialSource,
|
||||
selectedNotebookId, setSelectedNotebookId,
|
||||
selectedDocumentIds, setSelectedDocumentIds,
|
||||
modelName, setModelName,
|
||||
applyProposal,
|
||||
}),
|
||||
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal],
|
||||
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
|
||||
);
|
||||
|
||||
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
createResearchTemplate,
|
||||
deleteResearchTemplate,
|
||||
listResearchTemplates,
|
||||
updateResearchTemplate,
|
||||
} from '../api/ai-writing-research-templates';
|
||||
import type {
|
||||
CreateResearchTemplateRequest,
|
||||
ResearchTemplate,
|
||||
UpdateResearchTemplateRequest,
|
||||
} from '../api/ai-writing-research-templates';
|
||||
|
||||
export function useResearchTemplates() {
|
||||
const [templates, setTemplates] = useState<ResearchTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setTemplates(await listResearchTemplates());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const create = useCallback(
|
||||
async (data: CreateResearchTemplateRequest): Promise<ResearchTemplate> => {
|
||||
const created = await createResearchTemplate(data);
|
||||
setTemplates((prev) =>
|
||||
[...prev, created].sort((a, b) => a.sort_order - b.sort_order),
|
||||
);
|
||||
return created;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string, data: UpdateResearchTemplateRequest): Promise<ResearchTemplate> => {
|
||||
const updated = await updateResearchTemplate(id, data);
|
||||
setTemplates((prev) =>
|
||||
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
|
||||
);
|
||||
return updated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await deleteResearchTemplate(id);
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return { templates, loading, error, refresh, create, update, remove };
|
||||
}
|
||||
@@ -81,8 +81,8 @@
|
||||
* -d '{"retention_days": 0}'
|
||||
* ──────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { PanelRightOpen } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ArrowLeft, PanelRightOpen } from 'lucide-react';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GroupImperativeHandle } from 'react-resizable-panels';
|
||||
@@ -93,6 +93,13 @@ import { CanvasUIProvider } from '../contexts/CanvasUIContext';
|
||||
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
|
||||
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
||||
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
|
||||
import { ResearchSidebar } from '../components/ai-writing/ResearchSidebar';
|
||||
import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage';
|
||||
import StudioNotebooksPage from '@/pages/StudioNotebooksPage';
|
||||
import { ChatRuntime } from '@/pages/WorkspaceRoutes';
|
||||
|
||||
/** 右侧内嵌视图:笔记本详情 / 所有空间列表 / null(写作) */
|
||||
type RightView = { kind: 'notebook'; id: number } | { kind: 'allSpaces' } | null;
|
||||
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
|
||||
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
|
||||
import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext';
|
||||
@@ -221,17 +228,78 @@ export default function AIWritingPage({
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
} = {}) {
|
||||
// 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容
|
||||
// (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。
|
||||
const [rightView, setRightView] = useState<RightView>(null);
|
||||
const overlayActive = rightView != null;
|
||||
const viewingNotebookId = rightView?.kind === 'notebook' ? rightView.id : null;
|
||||
|
||||
return (
|
||||
<CanvasThreadProvider>
|
||||
<CanvasGraphProvider>
|
||||
<CanvasUIProvider>
|
||||
<AIWritingHistoryProvider>
|
||||
<AIWritingProvider>
|
||||
<WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}>
|
||||
<WritingFormProvider
|
||||
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex">
|
||||
{/* 需求 0616 #5:课题研究左栏(研究模板 + 笔记本)。studio 嵌入态(studioToken)隐藏。 */}
|
||||
{!studioToken && (
|
||||
<ResearchSidebar
|
||||
viewingNotebookId={viewingNotebookId}
|
||||
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
|
||||
onOpenAllSpaces={() => setRightView({ kind: 'allSpaces' })}
|
||||
/>
|
||||
)}
|
||||
{/* 右侧:写作布局常驻(仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程),
|
||||
查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<div className={cn('absolute inset-0', overlayActive && 'hidden')}>
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</div>
|
||||
{overlayActive && (
|
||||
<div className="absolute inset-0 flex flex-col bg-background">
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightView(null)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回写作
|
||||
</button>
|
||||
{rightView.kind === 'notebook' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightView({ kind: 'allSpaces' })}
|
||||
className="ml-1 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
所有空间
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{rightView.kind === 'notebook' ? (
|
||||
// 笔记本详情页内部用 ChatBox(useArtifacts) → 需 ChatRuntime 提供 Provider。
|
||||
<ChatRuntime>
|
||||
<StudioNotebookDetailPage key={rightView.id} notebookId={rightView.id} />
|
||||
</ChatRuntime>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<StudioNotebooksPage
|
||||
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, FileText, Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import { useResearchTemplates } from '../hooks/useResearchTemplates';
|
||||
import { useArticleTypes } from '../hooks/useArticleTypes';
|
||||
import type {
|
||||
CreateResearchTemplateRequest,
|
||||
ResearchTemplate,
|
||||
} from '../api/ai-writing-research-templates';
|
||||
|
||||
const INPUT_CLASS =
|
||||
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ' +
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50';
|
||||
const TEXTAREA_CLASS =
|
||||
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ' +
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none';
|
||||
const LABEL_CLASS = 'mb-1 block text-xs font-medium text-muted-foreground';
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
description: string;
|
||||
article_type_key: string;
|
||||
outline: string;
|
||||
material_source: 'general' | 'notebook';
|
||||
word_count: string;
|
||||
audience: string;
|
||||
strict_mode: boolean;
|
||||
topic: string;
|
||||
sort_order: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: '',
|
||||
description: '',
|
||||
article_type_key: '',
|
||||
outline: '',
|
||||
material_source: 'general',
|
||||
word_count: '800',
|
||||
audience: '',
|
||||
strict_mode: false,
|
||||
topic: '',
|
||||
sort_order: '0',
|
||||
};
|
||||
|
||||
function toForm(t: ResearchTemplate): FormState {
|
||||
return {
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
article_type_key: t.article_type_key ?? '',
|
||||
outline: t.outline ?? '',
|
||||
material_source: t.material_source,
|
||||
word_count: String(t.word_count),
|
||||
audience: t.audience ?? '',
|
||||
strict_mode: t.strict_mode,
|
||||
topic: t.topic ?? '',
|
||||
sort_order: String(t.sort_order),
|
||||
};
|
||||
}
|
||||
|
||||
function toPayload(form: FormState): CreateResearchTemplateRequest {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
article_type_key: form.article_type_key || null,
|
||||
outline: form.outline.trim() || null,
|
||||
material_source: form.material_source,
|
||||
word_count: Math.max(100, parseInt(form.word_count, 10) || 800),
|
||||
audience: form.audience.trim() || null,
|
||||
strict_mode: form.strict_mode,
|
||||
topic: form.topic.trim() || null,
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResearchTemplatesConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
const { templates, loading, error, refresh, create, update, remove } = useResearchTemplates();
|
||||
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
void refreshTypes();
|
||||
}, [refresh, refreshTypes]);
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (t: ResearchTemplate) => {
|
||||
setEditingId(t.id);
|
||||
setForm(toForm(t));
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) {
|
||||
setFormError('请填写模板名称');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const payload = toPayload(form);
|
||||
if (editingId) await update(editingId, payload);
|
||||
else await create(payload);
|
||||
setDialogOpen(false);
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTargetId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await remove(deleteTargetId);
|
||||
setDeleteTargetId(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const articleTypeLabel = (key: string | null) =>
|
||||
key ? articleTypes.find((t) => t.key === key)?.label ?? key : '—';
|
||||
|
||||
return (
|
||||
<div className="mx-auto h-full w-full max-w-4xl overflow-y-auto p-6">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">研究模板配置</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
配置课题研究左栏的研究模板;点击模板时一键预填写作表单。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
新建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-muted-foreground">
|
||||
暂无研究模板,点击右上角「新建模板」创建。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{templates.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-start gap-3 rounded-xl border border-border bg-card p-4"
|
||||
>
|
||||
<div className="bg-primary/10 text-primary mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{t.name}</span>
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{articleTypeLabel(t.article_type_key)}
|
||||
</span>
|
||||
</div>
|
||||
{t.description && (
|
||||
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{t.description}</p>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground/80">
|
||||
<span>来源:{t.material_source === 'notebook' ? '我的空间' : '通用检索'}</span>
|
||||
<span>字数:{t.word_count}</span>
|
||||
{t.audience && <span>受众:{t.audience}</span>}
|
||||
<span>{t.strict_mode ? '严格模式' : '宽松模式'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(t)} title="编辑">
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setDeleteTargetId(t.id)}
|
||||
title="删除"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建 / 编辑 弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={(v) => !saving && setDialogOpen(v)}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? '编辑研究模板' : '新建研究模板'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-2">
|
||||
<div className="col-span-2">
|
||||
<label className={LABEL_CLASS}>模板名称 *</label>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
placeholder="如:深度舆情分析"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className={LABEL_CLASS}>描述</label>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
value={form.description}
|
||||
onChange={(e) => set('description', e.target.value)}
|
||||
placeholder="模板用途简述"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>文章类型</label>
|
||||
<select
|
||||
className={INPUT_CLASS}
|
||||
value={form.article_type_key}
|
||||
onChange={(e) => set('article_type_key', e.target.value)}
|
||||
>
|
||||
<option value="">不设</option>
|
||||
{articleTypes.map((t) => (
|
||||
<option key={t.key} value={t.key}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>搜索来源</label>
|
||||
<select
|
||||
className={INPUT_CLASS}
|
||||
value={form.material_source}
|
||||
onChange={(e) => set('material_source', e.target.value as 'general' | 'notebook')}
|
||||
>
|
||||
<option value="general">通用检索</option>
|
||||
<option value="notebook">我的空间(笔记本)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>目标字数</label>
|
||||
<input
|
||||
type="number"
|
||||
className={INPUT_CLASS}
|
||||
value={form.word_count}
|
||||
onChange={(e) => set('word_count', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>目标受众</label>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
value={form.audience}
|
||||
onChange={(e) => set('audience', e.target.value)}
|
||||
placeholder="如:决策者"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className={LABEL_CLASS}>主题 / 意图</label>
|
||||
<textarea
|
||||
className={TEXTAREA_CLASS}
|
||||
rows={2}
|
||||
value={form.topic}
|
||||
onChange={(e) => set('topic', e.target.value)}
|
||||
placeholder="点击模板时预填到写作意图输入框"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className={LABEL_CLASS}>默认大纲</label>
|
||||
<textarea
|
||||
className={TEXTAREA_CLASS}
|
||||
rows={4}
|
||||
value={form.outline}
|
||||
onChange={(e) => set('outline', e.target.value)}
|
||||
placeholder={'一、…\n二、…\n三、…'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="rt-strict"
|
||||
type="checkbox"
|
||||
checked={form.strict_mode}
|
||||
onChange={(e) => set('strict_mode', e.target.checked)}
|
||||
className="size-4"
|
||||
/>
|
||||
<label htmlFor="rt-strict" className="text-sm">
|
||||
严格写作模式
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>排序(小在前)</label>
|
||||
<input
|
||||
type="number"
|
||||
className={INPUT_CLASS}
|
||||
value={form.sort_order}
|
||||
onChange={(e) => set('sort_order', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleSave()} disabled={saving}>
|
||||
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTargetId} onOpenChange={(v) => !deleting && !v && setDeleteTargetId(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除研究模板</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="py-2 text-sm text-muted-foreground">确定删除该研究模板吗?此操作不可恢复。</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTargetId(null)} disabled={deleting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void handleDelete()} disabled={deleting}>
|
||||
{deleting && <Loader2 className="size-4 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import CanvasHomePage from "../pages/CanvasHomePage";
|
||||
import CanvasThreadPage from "../pages/CanvasThreadPage";
|
||||
import AIWritingPage from "../pages/AIWritingPage";
|
||||
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
|
||||
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
|
||||
|
||||
export default function OpenCanvasRoutes() {
|
||||
return (
|
||||
@@ -11,6 +12,7 @@ export default function OpenCanvasRoutes() {
|
||||
<Route index element={<CanvasHomePage />} />
|
||||
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
|
||||
<Route path="article-types" element={<ArticleTypesConfigPage />} />
|
||||
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
|
||||
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -31,15 +31,23 @@ import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
export default function AgentChatPage({
|
||||
embeddedAgentId,
|
||||
}: {
|
||||
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
|
||||
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
|
||||
embeddedAgentId?: string;
|
||||
} = {}) {
|
||||
const routeBase = useRouteBase();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { agent_id } = useParams<{
|
||||
const { agent_id: agentIdFromPath } = useParams<{
|
||||
agent_id: string;
|
||||
}>();
|
||||
const agent_id = embeddedAgentId ?? agentIdFromPath;
|
||||
const isEmbedded = !!embeddedAgentId;
|
||||
|
||||
// If ChatPage stashed an opening message for this agent, skip the landing
|
||||
// screen on first render so the user doesn't see a flash of the welcome UI
|
||||
@@ -208,8 +216,12 @@ export default function AgentChatPage() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
|
||||
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
|
||||
router.push(
|
||||
`/page/workspace/agents/${agent_id}/chats/new`,
|
||||
isEmbedded
|
||||
? `/page/workspace/chats/new?agent=${agent_id}`
|
||||
: `${routeBase}/agents/${agent_id}/chats/new`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
AgentModuleSidebar,
|
||||
type AgentModuleConfig,
|
||||
} from "@/components/workspace/agents/agent-module-sidebar";
|
||||
import { AgentModuleSettingsDialog } from "@/components/workspace/agents/agent-module-settings-dialog";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import { useAgentModuleConfig } from "@/core/system-settings/hooks";
|
||||
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
|
||||
/**
|
||||
* 「按类型聚合」模块页(需求 0616 #3 zzJC / #4 zzXD,重做)。
|
||||
*
|
||||
* 一个**常驻外壳**(左栏:按类型分组智能体 + 聊天记录 + admin 设置)包裹右侧对话;
|
||||
* 点击智能体只在模块内切换对话(左栏不卸载,无整页跳转)。模块自带独立路由
|
||||
* ``/page/strategy/qa/{moduleKey}``,并以该路径作为 RouteBase,使内嵌的 AgentChatPage
|
||||
* 所有跳转(新建对话 / onStart 改写 hash)都留在模块子树内。
|
||||
*/
|
||||
export function AgentModuleRoutes({ module }: { module: AgentModuleConfig }) {
|
||||
const base = `/page/strategy/qa/${module.key}`;
|
||||
return (
|
||||
<RouteBaseProvider value={base}>
|
||||
<Routes>
|
||||
<Route index element={<ModuleDefaultRedirect module={module} />} />
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={<ModuleChatShell module={module} />}
|
||||
/>
|
||||
</Routes>
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 进入模块默认落地:配置类型下的第一个智能体;无配置回退到模块默认体。 */
|
||||
function ModuleDefaultRedirect({ module }: { module: AgentModuleConfig }) {
|
||||
const { agents, isLoading: agentsLoading } = useAgents();
|
||||
const { data: config, isLoading: configLoading } = useAgentModuleConfig(
|
||||
module.key,
|
||||
);
|
||||
|
||||
if (agentsLoading || configLoading) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tagIds = config?.tag_ids ?? [];
|
||||
let targetId = module.fallbackAgentId;
|
||||
if (tagIds.length > 0) {
|
||||
const allowed = new Set(tagIds);
|
||||
const first = agents.find((a) =>
|
||||
(a.tags ?? []).some((t) => allowed.has(t.id)),
|
||||
);
|
||||
if (first) targetId = first.id;
|
||||
}
|
||||
|
||||
return <Navigate to={`agents/${targetId}/chats/new`} replace />;
|
||||
}
|
||||
|
||||
/** 模块外壳:常驻左栏 + 内嵌当前智能体对话(按 agent_id 重建对话区,左栏保持挂载)。 */
|
||||
function ModuleChatShell({ module }: { module: AgentModuleConfig }) {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 overflow-hidden">
|
||||
<AgentModuleSidebar
|
||||
moduleKey={module.key}
|
||||
activeAgentId={agent_id}
|
||||
onOpenSettings={isAdmin ? () => setSettingsOpen(true) : undefined}
|
||||
/>
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<ChatRuntime>
|
||||
<AgentChatPage key={agent_id} />
|
||||
</ChatRuntime>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<AgentModuleSettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
moduleKey={module.key}
|
||||
moduleTitle={module.title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,10 +31,13 @@ import {
|
||||
type BusinessMapping,
|
||||
} from "@/strategy-components/api/business-mapping";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
|
||||
export default function BusinessChainMappingPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
// 整片脱敏:业务链条映射页展示文本(链条名/描述/席位名/标题)。输入框 Hook 自动跳过。
|
||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
const [rows, setRows] = useState<BusinessMapping[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
@@ -160,7 +163,7 @@ export default function BusinessChainMappingPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}>
|
||||
<div ref={dzRef} className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}>
|
||||
<div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8">
|
||||
|
||||
{/* ── 页头 banner ── */}
|
||||
|
||||
@@ -34,7 +34,7 @@ import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-rout
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PanelLeftOpen } from "lucide-react";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
@@ -130,6 +130,10 @@ export default function ChatPage() {
|
||||
const browserContextAutoSubmitRef = useRef<string | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
// 已选 agent 的存在性校验需要更宽的来源:侧边栏可选未配置进管理弹窗的 agent
|
||||
// (用户自己的智能体),这些也算有效,故把「我的 / 广场」一并纳入校验集(缓存共享)。
|
||||
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
@@ -142,7 +146,7 @@ export default function ChatPage() {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
// Pre-select the agent the user last started a conversation with so a
|
||||
// fresh chat defaults to it. Validated against the selector list below.
|
||||
// fresh chat defaults to it. Validated against the known agent list below.
|
||||
setSelectedAgentId(getLastAgentId());
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
@@ -152,10 +156,14 @@ export default function ChatPage() {
|
||||
// dead agent. Wait for the list to load before deciding.
|
||||
useEffect(() => {
|
||||
if (selectorLoading || !selectedAgentId) return;
|
||||
if (!selectorAgents.some((a) => a.id === selectedAgentId)) {
|
||||
// 校验集 = 管理弹窗 ∪ 我的 ∪ 广场;任一存在即视为有效(含用户未配置进管理弹窗
|
||||
// 的自有智能体)。三者都还没加载出来时不做清除,避免把恢复值误删。
|
||||
const known = [...selectorAgents, ...mineAgents, ...squareAgents];
|
||||
if (known.length === 0) return;
|
||||
if (!known.some((a) => a.id === selectedAgentId)) {
|
||||
setSelectedAgentId(null);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, selectorLoading]);
|
||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
@@ -11,6 +12,11 @@ import {
|
||||
readExternalLoginParams,
|
||||
resolveThemeFromQuery,
|
||||
} from '@/core/auth/external-login';
|
||||
import {
|
||||
fetchCopTaskDetail,
|
||||
persistCopTaskDetail,
|
||||
resolveTaskDeeplinkTarget,
|
||||
} from '@/core/auth/task-deeplink';
|
||||
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
||||
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||
import {
|
||||
@@ -34,6 +40,7 @@ type LoginStatus = 'loading' | 'success' | 'error';
|
||||
export default function LoginPage() {
|
||||
const { username } = useParams<{ username?: string }>();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
const sidebarEnabled = useMemo(
|
||||
@@ -60,6 +67,9 @@ export default function LoginPage() {
|
||||
const gatePassword = searchParams.get('password') ?? '';
|
||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||
const authToken = searchParams.get('authToken')?.trim() ?? '';
|
||||
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
|
||||
const taskId = searchParams.get('taskId')?.trim() ?? '';
|
||||
const goPath = searchParams.get('goPath')?.trim() ?? '';
|
||||
const externalParams = useMemo(
|
||||
() => ({
|
||||
theme: searchParams.get('theme') ?? '',
|
||||
@@ -145,6 +155,27 @@ export default function LoginPage() {
|
||||
persistAccessToken(accessToken);
|
||||
persistSidebarEnabled(sidebarEnabled);
|
||||
setStatus('success');
|
||||
|
||||
// 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页;
|
||||
// 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。
|
||||
if (taskId && goPath) {
|
||||
try {
|
||||
const detail = await fetchCopTaskDetail(taskId);
|
||||
if (cancelled) return;
|
||||
if (detail) {
|
||||
persistCopTaskDetail(detail);
|
||||
const target = await resolveTaskDeeplinkTarget(goPath, detail);
|
||||
if (cancelled) return;
|
||||
if (target) {
|
||||
navigate(target.path, { replace: true, state: target.state });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 深链失败不阻断登录,落默认首页 */
|
||||
}
|
||||
}
|
||||
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
@@ -164,6 +195,7 @@ export default function LoginPage() {
|
||||
}, [
|
||||
resolvedUsername,
|
||||
router,
|
||||
navigate,
|
||||
sidebarEnabled,
|
||||
yUserId,
|
||||
toolToken,
|
||||
@@ -171,6 +203,8 @@ export default function LoginPage() {
|
||||
onlineTicket,
|
||||
authToken,
|
||||
gatePassword,
|
||||
taskId,
|
||||
goPath,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,8 @@ import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainList
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import { AgentModuleRoutes } from "./AgentModulePage";
|
||||
import { AGENT_MODULES } from "@/components/workspace/agents/agent-module-sidebar";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import BusinessChainMappingPage from "./BusinessChainMappingPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
@@ -75,6 +77,10 @@ export default function StrategyRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 需求 0616 #3/#4:zzJC / zzXD 「按类型聚合」模块页(重做,独立路由)。 */}
|
||||
<Route path="qa/zzjc/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzjc} />} />
|
||||
<Route path="qa/zzxd/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzxd} />} />
|
||||
|
||||
<Route path="task-management" element={<TaskManagement />}>
|
||||
<Route index element={<Navigate to="xk-task-jc" replace />} />
|
||||
<Route
|
||||
|
||||
@@ -209,8 +209,15 @@ function SourcesDialog({
|
||||
}
|
||||
|
||||
// ── 主页面 ──────────────────────────────────────────────
|
||||
export default function StudioNotebookDetailPage() {
|
||||
const { notebookId } = useParams<{ notebookId: string }>();
|
||||
export default function StudioNotebookDetailPage({
|
||||
notebookId: notebookIdProp,
|
||||
}: {
|
||||
/** 内嵌使用(如课题研究右侧)时直接传入;缺省则从路由参数取。 */
|
||||
notebookId?: number;
|
||||
} = {}) {
|
||||
const params = useParams<{ notebookId: string }>();
|
||||
const notebookId =
|
||||
notebookIdProp != null ? String(notebookIdProp) : params.notebookId;
|
||||
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
|
||||
const { models } = useModels();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -39,17 +39,16 @@ function NotebookCard({
|
||||
nb,
|
||||
onRename,
|
||||
onDelete,
|
||||
onOpen,
|
||||
}: {
|
||||
nb: StudioNotebook;
|
||||
onRename: (nb: StudioNotebook) => void;
|
||||
onDelete: (nb: StudioNotebook) => void;
|
||||
/** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */
|
||||
onOpen?: (id: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className="flex flex-1 flex-col p-5"
|
||||
>
|
||||
const inner = (
|
||||
<>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
|
||||
{nb.avatar_url ? (
|
||||
@@ -67,7 +66,27 @@ function NotebookCard({
|
||||
</span>
|
||||
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
|
||||
{onOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(nb.id)}
|
||||
className="flex flex-1 flex-col p-5 text-left"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className="flex flex-1 flex-col p-5"
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* three-dot menu — absolute so it doesn't shift layout */}
|
||||
<div className="absolute right-4 top-4">
|
||||
@@ -99,7 +118,12 @@ function NotebookCard({
|
||||
);
|
||||
}
|
||||
|
||||
export default function StudioNotebooksPage() {
|
||||
export default function StudioNotebooksPage({
|
||||
onOpenNotebook,
|
||||
}: {
|
||||
/** 内嵌使用(课题研究右侧)时传入:点击空间卡片回调,不跳路由。 */
|
||||
onOpenNotebook?: (id: number) => void;
|
||||
} = {}) {
|
||||
const { notebooks, isLoading, isError } = useStudioNotebooks();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -204,6 +228,7 @@ export default function StudioNotebooksPage() {
|
||||
nb={nb}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
onOpen={onOpenNotebook}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
|
||||
@@ -87,6 +87,10 @@ export function WorkspaceLayout({
|
||||
insetOverflowHidden?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
}) {
|
||||
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
||||
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
||||
const isGeneralQa = useLocation().pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? '0rem'
|
||||
: pageSidebarDark
|
||||
@@ -113,7 +117,9 @@ export function WorkspaceLayout({
|
||||
}
|
||||
key={String(compactWorkspaceSidebar)}
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen={defaultHistorySidebarOpen ?? !compactWorkspaceSidebar}
|
||||
defaultOpen={
|
||||
defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar)
|
||||
}
|
||||
>
|
||||
{!workspaceSidebarHidden && (
|
||||
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
|
||||
@@ -139,6 +145,21 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用问答新对话内容区:默认渲染 ChatPage;当 URL 带 ``?agent=<id>``(点击左栏
|
||||
* 「常用智能体」时由侧边栏写入)时,**在右侧问答区原地嵌入**该智能体的对话页
|
||||
* (AgentChatPage),路由仍停留在 /chats/new,不跳转出去、不更换左侧栏。
|
||||
* key 绑定 location.key —— 每次点击(即便同一 agent)都重开一个干净的会话。
|
||||
*/
|
||||
function NewChatContent() {
|
||||
const location = useLocation();
|
||||
const agentId = new URLSearchParams(location.search).get('agent');
|
||||
if (agentId) {
|
||||
return <AgentChatPage key={`${agentId}:${location.key}`} embeddedAgentId={agentId} />;
|
||||
}
|
||||
return <ChatPage />;
|
||||
}
|
||||
|
||||
function NewChatEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
@@ -157,7 +178,7 @@ function NewChatEntryRoute({
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
<NewChatContent />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
@@ -212,9 +213,11 @@ export function BusinessChainListDialog({
|
||||
onOpenChange,
|
||||
onConfigure,
|
||||
}: BusinessChainListDialogProps) {
|
||||
// 整片脱敏:弹窗内链条列表(链条名/描述/席位名/标题)。
|
||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
|
||||
<DialogContent ref={dzRef} className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LayersIcon className="size-5 text-primary" />
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
import { filterRecommendCandidates } from "../api/recommend";
|
||||
@@ -161,9 +162,12 @@ export function BusinessChainPicker({
|
||||
onConfirm({ agents, mode: "chain", chain: chainMeta });
|
||||
};
|
||||
|
||||
// 整片脱敏:链条卡片区(链条名/描述/席位智能体名)。输入框不受影响(Hook 跳过)。
|
||||
const dzRef = useDesensitizeSubtree<HTMLElement>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<section ref={dzRef} className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{/* 筛选:我的 / 公共 */}
|
||||
<div className="mb-2 flex shrink-0 items-center gap-1 self-start rounded-lg border border-border bg-muted/40 p-0.5">
|
||||
{(["mine", "public"] as const).map((s) => (
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { getAvatar } from "./Avatars";
|
||||
@@ -137,7 +138,7 @@ export function DagFlowChart({
|
||||
return (
|
||||
<FlowNode
|
||||
key={agentId}
|
||||
label={meta.name}
|
||||
label={desensitize(meta.name)}
|
||||
avatarType={meta.avatarType}
|
||||
state={state}
|
||||
compact={compact}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { CheckIcon, Loader2Icon, Network, X } from "lucide-react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { getAvatar } from "./Avatars";
|
||||
@@ -400,7 +401,7 @@ export function DagFlowMiniPanel({
|
||||
return (
|
||||
<MiniNode
|
||||
key={agentId}
|
||||
label={meta.name}
|
||||
label={desensitize(meta.name)}
|
||||
avatarType={meta.avatarType}
|
||||
state={state}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { getAvatar } from "./Avatars";
|
||||
@@ -236,7 +237,7 @@ export function DispatchChainModal({
|
||||
done={node.state === "done"}
|
||||
/>
|
||||
<FlowNode
|
||||
label={node.name}
|
||||
label={desensitize(node.name)}
|
||||
avatarType={node.avatarType}
|
||||
state={node.state}
|
||||
order={node.order}
|
||||
@@ -277,7 +278,7 @@ export function DispatchChainModal({
|
||||
<div key={d.id} className="flex gap-2.5">
|
||||
<div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 text-xs font-semibold text-foreground/80">{d.name}</div>
|
||||
<div className="mb-0.5 text-xs font-semibold text-foreground/80">{desensitize(d.name)}</div>
|
||||
<div className="prose prose-sm max-w-none break-words text-sm leading-relaxed text-foreground/80 dark:prose-invert">
|
||||
<MarkdownContent>{d.content || "…"}</MarkdownContent>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import {
|
||||
Step3ReportMode,
|
||||
@@ -707,7 +708,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
lit ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{seat.displayName}
|
||||
{desensitize(seat.displayName)}
|
||||
</span>
|
||||
<span className="block truncate text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground">
|
||||
{lit ? `${seat.delivery!.length} 字` : "无交付"}
|
||||
@@ -771,7 +772,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
{consensusText ? (
|
||||
<div className="text-xs text-slate-700 leading-relaxed dark:text-foreground/80">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
{desensitize(consensusText)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
@@ -830,7 +831,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
{getAvatar(seat.avatarType, "h-5.5 w-5.5")}
|
||||
</span>
|
||||
<span className="text-xs font-black text-slate-800 truncate dark:text-foreground">
|
||||
{seat.displayName}
|
||||
{desensitize(seat.displayName)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0 dark:text-muted-foreground">
|
||||
@@ -858,7 +859,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{seat.delivery!}
|
||||
{desensitize(seat.delivery!)}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">该席位暂无交付内容。</p>
|
||||
|
||||
@@ -18,6 +18,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
@@ -58,7 +59,8 @@ export function MessageBubble({
|
||||
senderNameClassName,
|
||||
)}
|
||||
>
|
||||
{senderName}
|
||||
{/* 智能体名称需脱敏(消息列表里唯一要脱敏的就是名称)。 */}
|
||||
{desensitize(senderName)}
|
||||
</span>
|
||||
{badge && (
|
||||
<span className="roundtable-chat-meta font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
|
||||
@@ -190,6 +192,7 @@ export const sharedMarkdownComponents = {
|
||||
* 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。
|
||||
* 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。
|
||||
*/
|
||||
// 消息列表正文**不脱敏**(仅名称脱敏);MarkdownContent 是会商对话气泡正文渲染器,故原文渲染。
|
||||
export function MarkdownContent({ children }: { children: string }) {
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={sharedMarkdownComponents}>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
@@ -42,7 +43,7 @@ export function ParallelSeatStrip({ batch, selectedSeats, onToggleSeat }: Parall
|
||||
key={seat.agentId}
|
||||
type="button"
|
||||
onClick={() => onToggleSeat(seat.agentId)}
|
||||
title={`${seat.displayName}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`}
|
||||
title={`${desensitize(seat.displayName)}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`}
|
||||
className={cn(
|
||||
"relative shrink-0 rounded-full transition",
|
||||
isSelected ? "ring-2 ring-primary ring-offset-1" : "ring-1 ring-transparent hover:ring-slate-300",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { listAgents, type Agent } from "@/core/agents";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
@@ -1122,7 +1123,7 @@ function AgentCard({
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div>
|
||||
<div className="font-medium text-sm truncate">{desensitize(candidate.name || candidate.agent_id)}</div>
|
||||
{recommended && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
|
||||
推荐
|
||||
@@ -1131,12 +1132,12 @@ function AgentCard({
|
||||
</div>
|
||||
{candidate.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{candidate.description}
|
||||
{desensitize(candidate.description)}
|
||||
</div>
|
||||
)}
|
||||
{reason ? (
|
||||
<div className="text-xs text-primary/80 mt-1 italic">
|
||||
<span className="font-medium not-italic">推荐理由:</span> {reason}
|
||||
<span className="font-medium not-italic">推荐理由:</span> {desensitize(reason)}
|
||||
</div>
|
||||
) : streaming ? (
|
||||
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
|
||||
|
||||
@@ -56,6 +56,7 @@ import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToB
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import type {
|
||||
@@ -149,10 +150,13 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
setAttachedFiles([]);
|
||||
};
|
||||
|
||||
// 顶部标题栏整片脱敏(消息列表正文在下方,不在此容器内,保持原文)。
|
||||
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Dialogue Header */}
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
|
||||
<h3 className="text-sm font-extrabold text-slate-900">
|
||||
|
||||
@@ -81,6 +81,8 @@ import {
|
||||
getAvatarTypeFor,
|
||||
type RoleEntry,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
@@ -405,10 +407,13 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
isOrchestrating ||
|
||||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
|
||||
|
||||
// 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。
|
||||
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
const chatBody = (
|
||||
<>
|
||||
{/* Headline */}
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
||||
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
|
||||
<div>
|
||||
@@ -584,7 +589,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
/>
|
||||
) : (
|
||||
<div className="py-6 text-center text-xs text-slate-400">
|
||||
{seat.displayName} · 等待输出…
|
||||
{desensitize(seat.displayName)} · 等待输出…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -599,7 +604,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
<span className="roundtable-chat-meta font-medium text-slate-600">
|
||||
仅显示{" "}
|
||||
<span className="font-bold text-primary">
|
||||
{seatFilterLabel(seatFilter)}
|
||||
{desensitize(seatFilterLabel(seatFilter))}
|
||||
</span>{" "}
|
||||
的发言
|
||||
</span>
|
||||
@@ -615,7 +620,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
)}
|
||||
{seatFilter && visibleDialogues.length === 0 ? (
|
||||
<div className="py-10 text-center text-xs text-slate-400">
|
||||
没有匹配「{seatFilterLabel(seatFilter)}」的消息
|
||||
没有匹配「{desensitize(seatFilterLabel(seatFilter))}」的消息
|
||||
</div>
|
||||
) : (
|
||||
visibleDialogues.map((msg) => (
|
||||
@@ -878,7 +883,7 @@ export function Step2Message({
|
||||
title={
|
||||
isAvatarSelected
|
||||
? "取消筛选,显示全部发言"
|
||||
: `只看 ${msg.sender} 的发言`
|
||||
: `只看 ${desensitize(msg.sender)} 的发言`
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
|
||||
|
||||
@@ -116,6 +116,8 @@ export function Step2SandboxLayout({
|
||||
threadId={previewThreadId}
|
||||
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
|
||||
allowVirtualMarkdownEdit
|
||||
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
|
||||
disableDesensitize
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { desensitizeDeep } from "@/lib/desensitize";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
|
||||
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
|
||||
@@ -101,10 +102,12 @@ export function Step3DashboardMode({
|
||||
} = dashboard;
|
||||
|
||||
// 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。
|
||||
const report = useMemo(
|
||||
() => dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null),
|
||||
[dashboard.data, savedReport?.dashboardJson],
|
||||
);
|
||||
// 整体深度脱敏后再渲染——大屏(DashboardApp)与自包含 HTML(iframe/新窗口/分享/下载)
|
||||
// 都从这份 report 派生,故在此一处脱敏即覆盖大屏的所有展示文本(会商页非沙箱内容需脱敏)。
|
||||
const report = useMemo(() => {
|
||||
const raw = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null);
|
||||
return raw ? desensitizeDeep(raw) : raw;
|
||||
}, [dashboard.data, savedReport?.dashboardJson]);
|
||||
|
||||
// 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。
|
||||
const dashboardData = useMemo(
|
||||
|
||||
@@ -44,6 +44,7 @@ import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/Co
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode";
|
||||
@@ -407,7 +408,7 @@ export function Step3ReportMode({
|
||||
>
|
||||
{consensusText ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
{desensitize(consensusText)}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 italic">暂无总控结论。</p>
|
||||
@@ -773,14 +774,14 @@ function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[]
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ListOrdered className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{seat.content || "(空)"}
|
||||
{desensitize(seat.content || "(空)")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||
@@ -449,7 +450,7 @@ export function Step3SummaryMode({
|
||||
>
|
||||
{consensusText ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
{desensitize(consensusText)}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 italic">暂无总控结论。</p>
|
||||
@@ -846,14 +847,14 @@ function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[]
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{seat.content || "(空)"}
|
||||
{desensitize(seat.content || "(空)")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
|
||||
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
|
||||
import { CodeBlock } from "@/components/ai-elements/code-block";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
|
||||
|
||||
/**
|
||||
@@ -52,7 +53,7 @@ export function renderStepArgs(
|
||||
return (
|
||||
<>
|
||||
{displayName && (
|
||||
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult>
|
||||
<ChainOfThoughtSearchResult>{desensitize(displayName)}</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
{task && (
|
||||
<div className="roundtable-chat-message mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-slate-600">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 区域级脱敏 Hook —— 把某个容器(DOM 子树)里的**所有文本节点**做脱敏,
|
||||
* 用于「左/右侧面板 + 顶部标题」这类需要整片脱敏的区域:静态标题、动态内容、
|
||||
* 嵌套子组件文本一并覆盖,无需逐串包 `desensitize()`(那样易漏、难维护)。
|
||||
*
|
||||
* 用法:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 只读改文本节点(display-only),不碰 `<input>/<textarea>` 的 value、不改 React 状态/回传值;
|
||||
* - `desensitize` 幂等(替换词不再命中词表),MutationObserver 重入安全;改写前先 disconnect 防自触发循环;
|
||||
* - 想在区域内**保留原文**(如内嵌沙箱/消息列表),给该子树根加 `data-no-desensitize` 属性即可跳过。
|
||||
*
|
||||
* 注意:**不要**用它包「消息列表正文」或「沙箱产物预览」——这两处按需求保持原文,
|
||||
* 应作为独立容器、不挂本 Hook(智能体名称的脱敏单独在 MessageBubble 的 senderName 处理)。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
const _SKIP_TAGS = new Set(["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "IFRAME", "CODE", "PRE"]);
|
||||
|
||||
export function useDesensitizeSubtree<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = ref.current;
|
||||
if (!root || typeof MutationObserver === "undefined") return;
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
|
||||
const apply = () => {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
if (_SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
||||
// 显式标记 data-no-desensitize 的子树保留原文(如内嵌沙箱)。
|
||||
if (parent.closest("[data-no-desensitize]")) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
const edits: Array<[Text, string]> = [];
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
const textNode = current as Text;
|
||||
const original = textNode.nodeValue ?? "";
|
||||
if (original.trim()) {
|
||||
const masked = desensitize(original);
|
||||
if (masked !== original) edits.push([textNode, masked]);
|
||||
}
|
||||
current = walker.nextNode();
|
||||
}
|
||||
if (edits.length === 0) return;
|
||||
// 改写期间停掉观察,避免自身改动再次触发,改完恢复。
|
||||
observer?.disconnect();
|
||||
for (const [textNode, masked] of edits) textNode.nodeValue = masked;
|
||||
observer?.observe(root, { subtree: true, childList: true, characterData: true });
|
||||
};
|
||||
|
||||
apply();
|
||||
observer = new MutationObserver(() => apply());
|
||||
observer.observe(root, { subtree: true, childList: true, characterData: true });
|
||||
return () => observer?.disconnect();
|
||||
});
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
@@ -134,6 +135,8 @@ type ActiveDrag =
|
||||
export default function BusinessChainEditorPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
// 整片脱敏:链条配置页展示文本(链条名/描述/席位名/标题)。可编辑的 input/textarea 不脱敏(Hook 跳过),保证编辑用原文。
|
||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
// ─── 数据 ────────────────────────────────────────────────────────────
|
||||
const [pool, setPool] = useState<RecommendCandidate[]>([]);
|
||||
@@ -804,7 +807,7 @@ export default function BusinessChainEditorPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
|
||||
<div ref={dzRef} className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
|
||||
{/* 顶栏 */}
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
|
||||
<button
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { useRouteNavigate } from "@/core/route-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
|
||||
import { deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains";
|
||||
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
|
||||
@@ -79,6 +80,8 @@ const BRAND =
|
||||
|
||||
export default function BusinessChainListPage() {
|
||||
const navigate = useRouteNavigate();
|
||||
// 整片脱敏:链条列表页展示文本(链条名/描述/席位名/标题)。
|
||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
|
||||
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
|
||||
@@ -233,7 +236,7 @@ export default function BusinessChainListPage() {
|
||||
}, [chains, search, sortBy]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-col bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
|
||||
<div ref={dzRef} className="flex h-full min-h-0 w-full flex-col bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
|
||||
{/* 顶栏:标题 + 描述 + 新建链条 */}
|
||||
<header className="shrink-0 border-b border-zinc-200/80 bg-white/85 px-6 py-4 backdrop-blur-md dark:border-[#1A2F5A] dark:bg-[#122347]/80">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Cpu,
|
||||
Download,
|
||||
History,
|
||||
Layers,
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
Pencil,
|
||||
@@ -54,7 +55,9 @@ import {
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
|
||||
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
@@ -98,6 +101,7 @@ import {
|
||||
} from "@/roundtable-planning/lib/seat-filter";
|
||||
import {
|
||||
buildChainSeatSelection,
|
||||
type BusinessChain,
|
||||
type SeatSelectionResult,
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
|
||||
@@ -110,7 +114,8 @@ import {
|
||||
startJobKeepalive,
|
||||
} from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import { useRoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
|
||||
import { getChain } from "@/roundtable-planning/api/chains";
|
||||
import { getChain, listChains } from "@/roundtable-planning/api/chains";
|
||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
||||
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
|
||||
import { listAgents } from "@/core/agents";
|
||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||
@@ -776,6 +781,34 @@ export default function RoundtablePlanningPage() {
|
||||
[buildCurrentStep2Snapshot, drafts, mergeActiveRun, step2, step3Report, triggerToast],
|
||||
);
|
||||
|
||||
// 需求 0616 #2:Step1 左栏点击某条业务链 → 仅**勾选**该链(不直接进 Step 2)。
|
||||
// 复用 autoChainResult 机制:用户在第一步完成意图澄清后,点「进入多智能体研讨」时
|
||||
// onEnterRoundtable 的 `if (autoChainResult)` 分支会据此跳过选链弹窗、按该链进研讨。
|
||||
// 再次点击同一条链 = 取消勾选。
|
||||
const handleSelectChainFromSidebar = useCallback(
|
||||
async (chain: BusinessChain) => {
|
||||
if (autoChainResult?.chain?.id === chain.id) {
|
||||
setAutoChainResult(null);
|
||||
triggerToast(`已取消业务链「${chain.title}」`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const agents = await listAgents().catch(() => []);
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
const result = buildChainSeatSelection(chain, candidates);
|
||||
if (!result) {
|
||||
triggerToast("⚠️ 该业务链条暂无法选用(匹配到的席位不足)");
|
||||
return;
|
||||
}
|
||||
setAutoChainResult(result);
|
||||
triggerToast(`✅ 已选择业务链「${chain.title}」,完成第一步澄清后点「进入多智能体研讨」`);
|
||||
} catch {
|
||||
triggerToast("❌ 选择业务链失败,请重试");
|
||||
}
|
||||
},
|
||||
[autoChainResult, triggerToast],
|
||||
);
|
||||
|
||||
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
|
||||
// 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。
|
||||
useEffect(() => {
|
||||
@@ -1147,6 +1180,8 @@ export default function RoundtablePlanningPage() {
|
||||
// 真正建好的窗口里,用它让历史记录显示「挂起中」(作业建好后转 queued/running,由后端状态接管)。
|
||||
// resetToStep1 不卸载本页,故该 state 跨「跳转」存活。
|
||||
const [pendingSuspendIds, setPendingSuspendIds] = useState<Set<string>>(() => new Set());
|
||||
// 整片脱敏:顶部页头(步骤导航 / 历史记录 / 标题等)的所有文本。
|
||||
const headerDzRef = useDesensitizeSubtree<HTMLElement>();
|
||||
const markPendingSuspend = useCallback((draftId: string) => {
|
||||
setPendingSuspendIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1446,7 +1481,7 @@ export default function RoundtablePlanningPage() {
|
||||
)}
|
||||
|
||||
{/* HEADER */}
|
||||
<header className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
|
||||
<header ref={headerDzRef} className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
|
||||
<StepNavigation
|
||||
currentStep={currentStep}
|
||||
intentReady={step1.intentReady}
|
||||
@@ -1557,6 +1592,9 @@ export default function RoundtablePlanningPage() {
|
||||
onSelectStep2Run={handleSelectStep2Run}
|
||||
seatFilter={seatFilter}
|
||||
setSeatFilter={setSeatFilter}
|
||||
selectedChainId={autoChainResult?.chain?.id ?? null}
|
||||
onSelectChain={handleSelectChainFromSidebar}
|
||||
onOpenDraft={drafts.handleLoadDraft}
|
||||
/>
|
||||
|
||||
{/* MIDDLE VIEWPORT */}
|
||||
@@ -2056,6 +2094,12 @@ interface LeftSidebarProps {
|
||||
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
|
||||
seatFilter: SeatFilter | null;
|
||||
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
|
||||
/** 需求 0616 #2:当前已勾选的业务链 id(高亮用),来自 autoChainResult.chain.id。 */
|
||||
selectedChainId: string | null;
|
||||
/** 需求 0616 #2:Step1 落地左栏「业务链」点击 → 勾选该链(不直接进研讨)。 */
|
||||
onSelectChain: (chain: BusinessChain) => void;
|
||||
/** 需求 0616 #2:Step1 落地左栏「分析记录」点击 → 打开历史会商分析(加载草稿)。 */
|
||||
onOpenDraft: (draftId: string) => void;
|
||||
}
|
||||
|
||||
function Step2SandboxSidebarSync({
|
||||
@@ -2097,7 +2141,13 @@ function LeftSidebar({
|
||||
onSelectStep2Run,
|
||||
seatFilter,
|
||||
setSeatFilter,
|
||||
selectedChainId,
|
||||
onSelectChain,
|
||||
onOpenDraft,
|
||||
}: LeftSidebarProps) {
|
||||
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
||||
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
||||
const dzRef = useDesensitizeSubtree<HTMLElement>();
|
||||
const isStep2 = currentStep === 2;
|
||||
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
|
||||
const toggleSeatFilter = (filter: SeatFilter) =>
|
||||
@@ -2109,6 +2159,7 @@ function LeftSidebar({
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={dzRef}
|
||||
id="left-sidebar"
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out",
|
||||
@@ -2223,28 +2274,147 @@ function LeftSidebar({
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<TaskOverviewCard currentStep={currentStep} />
|
||||
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
|
||||
</div>
|
||||
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
|
||||
{roles.map((role) => (
|
||||
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HumanParticipationCard />
|
||||
</>
|
||||
// Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
|
||||
<RoundtableEntryRail
|
||||
selectedChainId={selectedChainId}
|
||||
onSelectChain={onSelectChain}
|
||||
onOpenDraft={onOpenDraft}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 需求 0616 #2:多智能体会商 Step1(落地)左栏入口栏。
|
||||
* - 业务链:仅公共链条(``listChains("public")``)前 3 个;点击以该链席位启动会商。
|
||||
* - 分析记录:圆桌历史草稿(``useRoundtableDrafts``);点击打开对应历史会商分析。
|
||||
* 进入研讨(Step 2/3)后左栏恢复为「参与角色 / 任务概览」工作面板。
|
||||
*/
|
||||
function RoundtableEntryRail({
|
||||
selectedChainId,
|
||||
onSelectChain,
|
||||
onOpenDraft,
|
||||
}: {
|
||||
selectedChainId: string | null;
|
||||
onSelectChain: (chain: BusinessChain) => void;
|
||||
onOpenDraft: (draftId: string) => void;
|
||||
}) {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const { drafts: records, refresh } = useRoundtableDrafts();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void listChains("public")
|
||||
.then((all) => {
|
||||
if (!cancelled) setChains(all.slice(0, 3));
|
||||
})
|
||||
.catch(() => {
|
||||
/* 业务链非关键,静默忽略 */
|
||||
});
|
||||
void refresh();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const formatTime = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 业务链(公共,前 3 个) */}
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||
<Layers className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">业务链</h2>
|
||||
</div>
|
||||
{chains.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{chains.map((chain) => {
|
||||
const selected = selectedChainId === chain.id;
|
||||
return (
|
||||
<button
|
||||
key={chain.id}
|
||||
type="button"
|
||||
onClick={() => onSelectChain(chain)}
|
||||
title={selected ? "再次点击取消勾选" : `勾选业务链「${chain.title}」`}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"w-full rounded-xl border p-2.5 text-left transition-colors",
|
||||
selected
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-slate-100 bg-slate-50/60 hover:border-primary/30 hover:bg-white",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-xs font-extrabold",
|
||||
selected ? "text-primary" : "text-slate-800",
|
||||
)}
|
||||
>
|
||||
{chain.title}
|
||||
</span>
|
||||
{selected ? (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-semibold text-slate-400">
|
||||
{chain.seats.length} 个席位
|
||||
{selected ? " · 已选" : ""}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分析记录(圆桌历史草稿) */}
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[200px]">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||
<History className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">分析记录</h2>
|
||||
</div>
|
||||
{records.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 py-1.5">暂无分析记录</p>
|
||||
) : (
|
||||
<div className="space-y-2 overflow-y-auto flex-1 pr-1 custom-scrollbar">
|
||||
{records.map((rec) => (
|
||||
<button
|
||||
key={rec.id}
|
||||
type="button"
|
||||
onClick={() => onOpenDraft(rec.id)}
|
||||
title={rec.title}
|
||||
className="w-full rounded-xl border border-slate-100 bg-slate-50/60 p-2.5 text-left transition-colors hover:border-primary/20 hover:bg-white"
|
||||
>
|
||||
<div className="truncate text-xs font-bold text-slate-700">{rec.title}</div>
|
||||
<div className="mt-1 text-[10px] font-semibold text-slate-400">
|
||||
{formatTime(rec.updatedAt)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step2RunSwitcher({
|
||||
runs,
|
||||
activeRunId,
|
||||
@@ -2869,14 +3039,14 @@ function RoleAvatarBadge({
|
||||
);
|
||||
|
||||
if (!clickable) {
|
||||
return <div title={`${role.name} · ${role.status}`}>{inner}</div>;
|
||||
return <div title={`${desensitize(role.name)} · ${role.status}`}>{inner}</div>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={active}
|
||||
title={active ? "取消筛选,显示全部发言" : `只看 ${role.name} 的发言`}
|
||||
title={active ? "取消筛选,显示全部发言" : `只看 ${desensitize(role.name)} 的发言`}
|
||||
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
>
|
||||
{inner}
|
||||
@@ -2916,7 +3086,7 @@ function RoleRow({
|
||||
clickable
|
||||
? active
|
||||
? "取消筛选,显示全部发言"
|
||||
: `只看 ${role.name} 的发言`
|
||||
: `只看 ${desensitize(role.name)} 的发言`
|
||||
: undefined
|
||||
}
|
||||
onClick={onSelect}
|
||||
@@ -2946,7 +3116,7 @@ function RoleRow({
|
||||
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-bold leading-5 tracking-tight text-slate-800">
|
||||
{role.name}
|
||||
{desensitize(role.name)}
|
||||
</span>
|
||||
{role.type === "agent" && (
|
||||
<span className="text-[11px] text-primary font-medium leading-4">AI 智能体</span>
|
||||
@@ -3016,10 +3186,13 @@ function RightSidebar({
|
||||
triggerToast,
|
||||
onSaveIntent,
|
||||
}: RightSidebarProps) {
|
||||
// 整片脱敏:右侧面板(推荐模型 / 意图摘要 / 澄清记录)所有文本。
|
||||
const dzRef = useDesensitizeSubtree<HTMLElement>();
|
||||
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
|
||||
if (currentStep !== 1) return null;
|
||||
return (
|
||||
<section
|
||||
ref={dzRef}
|
||||
id="right-sidebar"
|
||||
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
|
||||
>
|
||||
|
||||
@@ -330,6 +330,10 @@ Admin-configurable, **access-control-free** categories that published agents/ski
|
||||
|
||||
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned ? resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries.
|
||||
|
||||
### Agent Module Tag Config (zzJC / zzXD 按类型聚合)
|
||||
|
||||
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
|
||||
|
||||
### Portable Column Types (`deerflow/persistence/types.py`)
|
||||
|
||||
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
|
||||
|
||||
@@ -168,6 +168,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.article_type_store = make_article_type_store(sf)
|
||||
|
||||
from deerflow.persistence.research_templates import make_research_template_store
|
||||
|
||||
app.state.research_template_store = make_research_template_store(sf)
|
||||
|
||||
from deerflow.persistence.memory_config import make_user_memory_config_store
|
||||
|
||||
app.state.memory_config_store = make_user_memory_config_store(sf)
|
||||
@@ -423,6 +427,11 @@ def get_article_type_store(request: Request):
|
||||
return getattr(request.app.state, "article_type_store", None)
|
||||
|
||||
|
||||
def get_research_template_store(request: Request):
|
||||
"""Return the research template store, or None when not configured."""
|
||||
return getattr(request.app.state, "research_template_store", None)
|
||||
|
||||
|
||||
def get_memory_config_store(request: Request):
|
||||
"""Return the per-user memory config store."""
|
||||
val = getattr(request.app.state, "memory_config_store", None)
|
||||
|
||||
@@ -90,6 +90,48 @@ class ArticleTypeResponse(BaseModel):
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ResearchTemplateRequest(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
article_type_key: Optional[str] = None
|
||||
outline: Optional[str] = None
|
||||
material_source: str = "general"
|
||||
word_count: int = 800
|
||||
audience: Optional[str] = None
|
||||
strict_mode: bool = False
|
||||
topic: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class UpdateResearchTemplateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
article_type_key: Optional[str] = None
|
||||
outline: Optional[str] = None
|
||||
material_source: Optional[str] = None
|
||||
word_count: Optional[int] = None
|
||||
audience: Optional[str] = None
|
||||
strict_mode: Optional[bool] = None
|
||||
topic: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class ResearchTemplateResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
article_type_key: Optional[str] = None
|
||||
outline: Optional[str] = None
|
||||
material_source: str
|
||||
word_count: int
|
||||
audience: Optional[str] = None
|
||||
strict_mode: bool
|
||||
topic: Optional[str] = None
|
||||
sort_order: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
id: str
|
||||
user_id: Optional[str] = None
|
||||
@@ -268,6 +310,94 @@ async def delete_article_type(article_type_id: str, request: Request):
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ── 研究模板(课题研究左栏一键预填表单,需求 0616 #5)────────────────────────────
|
||||
|
||||
|
||||
@router.get("/research-templates", response_model=list[ResearchTemplateResponse])
|
||||
async def list_research_templates(request: Request):
|
||||
"""列出所有研究模板,按 sort_order 升序。
|
||||
|
||||
新机器/空表时自动写入内置范例模板(网摘/周报/要讯),幂等仅在表为空时执行。
|
||||
"""
|
||||
from app.gateway.deps import get_research_template_store
|
||||
|
||||
store = get_research_template_store(request)
|
||||
if store is None:
|
||||
return []
|
||||
try:
|
||||
await store.seed_defaults()
|
||||
except Exception:
|
||||
logger.exception("ai_writing: failed to seed default research templates")
|
||||
return await store.list()
|
||||
|
||||
|
||||
@router.post("/research-templates", response_model=ResearchTemplateResponse, status_code=201)
|
||||
async def create_research_template(body: ResearchTemplateRequest, request: Request):
|
||||
"""新增研究模板。"""
|
||||
from app.gateway.deps import get_research_template_store
|
||||
|
||||
store = get_research_template_store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Research template store not available")
|
||||
return await store.create(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
article_type_key=body.article_type_key,
|
||||
outline=body.outline,
|
||||
material_source=body.material_source,
|
||||
word_count=body.word_count,
|
||||
audience=body.audience,
|
||||
strict_mode=body.strict_mode,
|
||||
topic=body.topic,
|
||||
sort_order=body.sort_order,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/research-templates/{template_id}", response_model=ResearchTemplateResponse)
|
||||
async def update_research_template(template_id: str, body: UpdateResearchTemplateRequest, request: Request):
|
||||
"""更新研究模板字段(只传需要修改的字段)。"""
|
||||
from app.gateway.deps import get_research_template_store
|
||||
|
||||
store = get_research_template_store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Research template store not available")
|
||||
|
||||
kwargs = {}
|
||||
for field in (
|
||||
"name",
|
||||
"description",
|
||||
"article_type_key",
|
||||
"outline",
|
||||
"material_source",
|
||||
"word_count",
|
||||
"audience",
|
||||
"strict_mode",
|
||||
"topic",
|
||||
"sort_order",
|
||||
):
|
||||
val = getattr(body, field)
|
||||
if val is not None:
|
||||
kwargs[field] = val
|
||||
|
||||
updated = await store.update(template_id, **kwargs)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Research template not found")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/research-templates/{template_id}")
|
||||
async def delete_research_template(template_id: str, request: Request):
|
||||
"""删除研究模板。"""
|
||||
from app.gateway.deps import get_research_template_store
|
||||
|
||||
store = get_research_template_store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Research template store not available")
|
||||
deleted = await store.delete(template_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Research template not found")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ── 管理接口 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@ from deerflow.config.system_settings import (
|
||||
PromptPrefixSettings,
|
||||
PublishSquare,
|
||||
PublishSquaresSettings,
|
||||
SystemSettings,
|
||||
get_module_tag_ids,
|
||||
list_publish_squares,
|
||||
load_system_settings,
|
||||
resolve_default_square_id,
|
||||
save_system_settings,
|
||||
set_module_tag_ids,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -377,3 +378,35 @@ async def get_log_system(request: Request) -> LogSystemResponse:
|
||||
await _require_admin(request)
|
||||
cfg = get_app_config().log_system
|
||||
return LogSystemResponse(enabled=cfg.enabled, url=cfg.url, label=cfg.label)
|
||||
|
||||
|
||||
class AgentModuleConfigResponse(BaseModel):
|
||||
"""Selected agent-tag ids for a「按类型聚合」module (zzJC / zzXD)."""
|
||||
|
||||
key: str = Field(..., description="Module key, e.g. zzjc / zzxd")
|
||||
tag_ids: list[str] = Field(default_factory=list, description="Selected agent tag ids")
|
||||
|
||||
|
||||
class AgentModuleConfigUpdateRequest(BaseModel):
|
||||
tag_ids: list[str] = Field(default_factory=list, description="Full replacement list of selected agent tag ids")
|
||||
|
||||
|
||||
@router.get("/agent-modules/{key}", response_model=AgentModuleConfigResponse)
|
||||
async def get_agent_module_config(key: str) -> AgentModuleConfigResponse:
|
||||
"""Return the agent tag ids configured for ``key`` (readable by any user —
|
||||
the module's left sidebar needs it to filter the agent list)."""
|
||||
settings = load_system_settings().agent_modules
|
||||
return AgentModuleConfigResponse(key=key, tag_ids=get_module_tag_ids(settings, key))
|
||||
|
||||
|
||||
@router.put("/agent-modules/{key}", response_model=AgentModuleConfigResponse)
|
||||
async def update_agent_module_config(
|
||||
key: str, body: AgentModuleConfigUpdateRequest, request: Request
|
||||
) -> AgentModuleConfigResponse:
|
||||
"""Replace the agent tag ids configured for ``key``. Admin only."""
|
||||
await _require_admin(request)
|
||||
|
||||
current = load_system_settings()
|
||||
updated = set_module_tag_ids(current.agent_modules, key, body.tag_ids)
|
||||
saved = save_system_settings(current.model_copy(update={"agent_modules": updated}))
|
||||
return AgentModuleConfigResponse(key=key, tag_ids=get_module_tag_ids(saved.agent_modules, key))
|
||||
@@ -170,6 +170,37 @@ class LeaderboardSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class AgentModuleSettings(BaseModel):
|
||||
"""Admin-configured「按类型聚合」module → selected agent-tag ids.
|
||||
|
||||
Keyed by module key (e.g. ``"zzjc"`` / ``"zzxd"``). Each value is the list
|
||||
of agent **tag ids** whose agents that module's left sidebar shows. A missing
|
||||
key / empty list means "not configured" — the frontend falls back to showing
|
||||
all visible agents grouped by tag.
|
||||
"""
|
||||
|
||||
modules: dict[str, list[str]] = Field(
|
||||
default_factory=dict,
|
||||
description="module key → selected agent tag ids",
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
def get_module_tag_ids(settings: AgentModuleSettings, key: str) -> list[str]:
|
||||
"""Return the configured agent-tag ids for ``key`` (empty when unset)."""
|
||||
return list(settings.modules.get(key, []))
|
||||
|
||||
|
||||
def set_module_tag_ids(
|
||||
settings: AgentModuleSettings, key: str, tag_ids: list[str]
|
||||
) -> AgentModuleSettings:
|
||||
"""Return a copy with ``key`` set to a de-duplicated, stripped tag-id list."""
|
||||
cleaned = list(dict.fromkeys([str(t).strip() for t in tag_ids if str(t).strip()]))
|
||||
modules = dict(settings.modules)
|
||||
modules[key] = cleaned
|
||||
return settings.model_copy(update={"modules": modules})
|
||||
|
||||
|
||||
class SystemSettings(BaseModel):
|
||||
"""Top-level system settings document."""
|
||||
|
||||
@@ -179,6 +210,7 @@ class SystemSettings(BaseModel):
|
||||
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
||||
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
|
||||
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
|
||||
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
|
||||
+2
@@ -42,6 +42,7 @@ from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||
from deerflow.persistence.research_templates.model import ResearchTemplateRow
|
||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
|
||||
from deerflow.persistence.roundtable_drafts.model import (
|
||||
@@ -89,6 +90,7 @@ __all__ = [
|
||||
"NotificationRow",
|
||||
"ParallelAgentTaskRow",
|
||||
"RecommendedQuestionRow",
|
||||
"ResearchTemplateRow",
|
||||
"RoundtableChainRow",
|
||||
"RoundtableDraftRow",
|
||||
"RoundtableDraftShareRow",
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
from deerflow.persistence.research_templates.model import ResearchTemplateRow
|
||||
from deerflow.persistence.research_templates.sql import ResearchTemplateRepository
|
||||
|
||||
|
||||
def make_research_template_store(session_factory) -> ResearchTemplateRepository | None:
|
||||
if session_factory is None:
|
||||
return None
|
||||
return ResearchTemplateRepository(session_factory)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ResearchTemplateRow",
|
||||
"ResearchTemplateRepository",
|
||||
"make_research_template_store",
|
||||
]
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
"""ORM model for AI writing research templates (课题研究模板).
|
||||
|
||||
A research template bundles a set of writing-form presets — article type,
|
||||
outline, material source, word count, audience, strict mode and a topic/intent —
|
||||
so a user can one-click pre-fill the whole 课题研究 form. Maintained from the
|
||||
research-template config page (需求 0616 #5).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class ResearchTemplateRow(Base):
|
||||
__tablename__ = "ai_writing_research_templates"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# 预设字段(点击模板时回填表单)。
|
||||
article_type_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
outline: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
material_source: Mapped[str] = mapped_column(String(32), nullable=False, default="general")
|
||||
word_count: Mapped[int] = mapped_column(Integer, nullable=False, default=800)
|
||||
audience: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
strict_mode: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
topic: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
"""SQLAlchemy-backed research template storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.research_templates.model import ResearchTemplateRow
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
# 新机器内置范例模板(表为空时由 seed_defaults() 写入;作为给用户的范例,内容务求规范好用)。
|
||||
# article_type_key 取自默认文章类型(news/analysis/feature/report,见 20260517_02 迁移)。
|
||||
DEFAULT_RESEARCH_TEMPLATES: list[dict] = [
|
||||
{
|
||||
"name": "网摘",
|
||||
"description": "汇编近期网络热点信息,分条摘录核心要点并标注来源,快速掌握全貌",
|
||||
"article_type_key": "news",
|
||||
"material_source": "general",
|
||||
"word_count": 1000,
|
||||
"audience": "关注热点动态的一般读者",
|
||||
"strict_mode": False,
|
||||
"topic": (
|
||||
"围绕【主题】检索并汇编近一周网络上的重要信息与热点动态,"
|
||||
"逐条摘录核心事实、关键数据与原文出处,帮助读者快速掌握全貌。"
|
||||
),
|
||||
"outline": (
|
||||
"一、热点速览(用一句话清单列出本期要点)\n"
|
||||
"二、重点摘录\n"
|
||||
" 1.【事件/话题标题】——核心事实、关键数据、信息来源\n"
|
||||
" 2.【事件/话题标题】——……\n"
|
||||
" 3.(按重要性依次罗列)\n"
|
||||
"三、关联动态与背景补充(串联相关信息、补足来龙去脉)\n"
|
||||
"四、小结与观察(提炼趋势或值得关注的看点)"
|
||||
),
|
||||
"sort_order": 0,
|
||||
},
|
||||
{
|
||||
"name": "周报",
|
||||
"description": "按周梳理工作进展、数据指标与问题风险,输出结构化周度汇报",
|
||||
"article_type_key": "report",
|
||||
"material_source": "general",
|
||||
"word_count": 1200,
|
||||
"audience": "团队成员与上级主管",
|
||||
"strict_mode": True,
|
||||
"topic": (
|
||||
"汇总本周【项目/团队】的工作进展,撰写周报:"
|
||||
"覆盖关键成果、数据指标、问题风险与下周计划,简明、客观、可量化。"
|
||||
),
|
||||
"outline": (
|
||||
"一、本周概览(一句话总结 + 关键指标对比上周)\n"
|
||||
"二、重点进展\n"
|
||||
" 1. 已完成事项与成果(结论先行,附支撑数据)\n"
|
||||
" 2. 关键数据 / 里程碑达成情况\n"
|
||||
"三、问题与风险(逐条列出问题、影响面及应对措施)\n"
|
||||
"四、下周计划与资源需求(目标、负责人、所需支持)"
|
||||
),
|
||||
"sort_order": 1,
|
||||
},
|
||||
{
|
||||
"name": "要讯",
|
||||
"description": "提炼最重要的资讯,精炼成简报式要点,突出核心事实与潜在影响",
|
||||
"article_type_key": "analysis",
|
||||
"material_source": "general",
|
||||
"word_count": 600,
|
||||
"audience": "需快速掌握要点的决策者",
|
||||
"strict_mode": True,
|
||||
"topic": (
|
||||
"围绕【主题/领域】提炼近期最重要的资讯,编写要讯:"
|
||||
"每条聚焦核心事实、关键背景与潜在影响,文字精炼、判断审慎。"
|
||||
),
|
||||
"outline": (
|
||||
"一、头条要讯(1–3 条,每条用一句话点明核心)\n"
|
||||
"二、要点详述(逐条展开)\n"
|
||||
" · 事实:发生了什么、何时何地、涉及谁\n"
|
||||
" · 背景:为何重要、与既有态势的关系\n"
|
||||
" · 影响:可能带来的后果与连锁反应\n"
|
||||
"三、研判与建议(给出倾向性判断与可行动建议)"
|
||||
),
|
||||
"sort_order": 2,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ResearchTemplateRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: ResearchTemplateRow) -> dict:
|
||||
d = row.to_dict()
|
||||
for k in ("created_at", "updated_at"):
|
||||
if isinstance(d.get(k), datetime):
|
||||
d[k] = d[k].isoformat()
|
||||
return d
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
article_type_key: str | None = None,
|
||||
outline: str | None = None,
|
||||
material_source: str = "general",
|
||||
word_count: int = 800,
|
||||
audience: str | None = None,
|
||||
strict_mode: bool = False,
|
||||
topic: str | None = None,
|
||||
sort_order: int = 0,
|
||||
) -> dict:
|
||||
now = datetime.now(UTC)
|
||||
row = ResearchTemplateRow(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
description=description,
|
||||
article_type_key=article_type_key,
|
||||
outline=outline,
|
||||
material_source=material_source,
|
||||
word_count=word_count,
|
||||
audience=audience,
|
||||
strict_mode=strict_mode,
|
||||
topic=topic,
|
||||
sort_order=sort_order,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
template_id: str,
|
||||
*,
|
||||
name=_UNSET,
|
||||
description=_UNSET,
|
||||
article_type_key=_UNSET,
|
||||
outline=_UNSET,
|
||||
material_source=_UNSET,
|
||||
word_count=_UNSET,
|
||||
audience=_UNSET,
|
||||
strict_mode=_UNSET,
|
||||
topic=_UNSET,
|
||||
sort_order=_UNSET,
|
||||
) -> dict | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ResearchTemplateRow, template_id)
|
||||
if row is None:
|
||||
return None
|
||||
if name is not _UNSET:
|
||||
row.name = name
|
||||
if description is not _UNSET:
|
||||
row.description = description
|
||||
if article_type_key is not _UNSET:
|
||||
row.article_type_key = article_type_key
|
||||
if outline is not _UNSET:
|
||||
row.outline = outline
|
||||
if material_source is not _UNSET:
|
||||
row.material_source = material_source
|
||||
if word_count is not _UNSET:
|
||||
row.word_count = word_count
|
||||
if audience is not _UNSET:
|
||||
row.audience = audience
|
||||
if strict_mode is not _UNSET:
|
||||
row.strict_mode = strict_mode
|
||||
if topic is not _UNSET:
|
||||
row.topic = topic
|
||||
if sort_order is not _UNSET:
|
||||
row.sort_order = sort_order
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def get(self, template_id: str) -> dict | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ResearchTemplateRow, template_id)
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def list(self) -> list[dict]:
|
||||
stmt = select(ResearchTemplateRow).order_by(
|
||||
ResearchTemplateRow.sort_order.asc(), ResearchTemplateRow.created_at.asc()
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
|
||||
async def delete(self, template_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ResearchTemplateRow, template_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def seed_defaults(self) -> int:
|
||||
"""新机器/空表时写入内置范例模板(网摘/周报/要讯)。
|
||||
|
||||
幂等:仅当表为空时插入;已有任何模板则跳过(避免覆盖用户配置或重复播种)。
|
||||
返回插入条数。
|
||||
"""
|
||||
async with self._sf() as session:
|
||||
existing = await session.execute(select(ResearchTemplateRow.id).limit(1))
|
||||
if existing.first() is not None:
|
||||
return 0
|
||||
now = datetime.now(UTC)
|
||||
for t in DEFAULT_RESEARCH_TEMPLATES:
|
||||
session.add(
|
||||
ResearchTemplateRow(id=str(uuid.uuid4()), created_at=now, updated_at=now, **t)
|
||||
)
|
||||
await session.commit()
|
||||
return len(DEFAULT_RESEARCH_TEMPLATES)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for the admin-configured「按类型聚合」module → agent-tag config.
|
||||
|
||||
Backs the zzJC / zzXD module left sidebar (需求 0616 #3/#4): an admin picks
|
||||
which agent tags belong to the module, persisted in ``system_settings.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.config import system_settings as ss
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_runtime_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||
"""Redirect ``runtime_home()`` to a per-test tmp dir."""
|
||||
|
||||
def fake_home() -> Path:
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ss, "runtime_home", fake_home)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_load_returns_empty_modules_when_missing(tmp_runtime_home: Path) -> None:
|
||||
settings = ss.load_system_settings()
|
||||
assert settings.agent_modules.modules == {}
|
||||
assert ss.get_module_tag_ids(settings.agent_modules, "zzjc") == []
|
||||
|
||||
|
||||
def test_set_module_tag_ids_dedupes_and_strips() -> None:
|
||||
base = ss.AgentModuleSettings()
|
||||
updated = ss.set_module_tag_ids(base, "zzjc", [" t1 ", "t2", "t1", "", " "])
|
||||
assert updated.modules["zzjc"] == ["t1", "t2"]
|
||||
# Original is untouched (returns a copy).
|
||||
assert base.modules == {}
|
||||
|
||||
|
||||
def test_set_module_tag_ids_keeps_other_modules() -> None:
|
||||
base = ss.set_module_tag_ids(ss.AgentModuleSettings(), "zzjc", ["t1"])
|
||||
both = ss.set_module_tag_ids(base, "zzxd", ["t2", "t3"])
|
||||
assert ss.get_module_tag_ids(both, "zzjc") == ["t1"]
|
||||
assert ss.get_module_tag_ids(both, "zzxd") == ["t2", "t3"]
|
||||
|
||||
|
||||
def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
|
||||
current = ss.load_system_settings()
|
||||
updated = ss.set_module_tag_ids(current.agent_modules, "zzjc", ["tag-a", "tag-b"])
|
||||
ss.save_system_settings(current.model_copy(update={"agent_modules": updated}))
|
||||
|
||||
file_path = tmp_runtime_home / "system_settings.json"
|
||||
assert file_path.is_file()
|
||||
saved = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
assert saved["agent_modules"]["modules"]["zzjc"] == ["tag-a", "tag-b"]
|
||||
|
||||
reloaded = ss.load_system_settings()
|
||||
assert ss.get_module_tag_ids(reloaded.agent_modules, "zzjc") == ["tag-a", "tag-b"]
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for the research-template store (课题研究模板,需求 0616 #5).
|
||||
|
||||
A research template bundles writing-form presets (article type / outline /
|
||||
material source / word count / audience / strict mode / topic) for one-click
|
||||
pre-fill. Covers create (with defaults) / list ordering / get / partial update /
|
||||
delete.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.research_templates import ResearchTemplateRepository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'rt.db'}")
|
||||
async with engine.begin() as conn:
|
||||
import deerflow.persistence.research_templates.model # noqa: F401
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
return ResearchTemplateRepository(sf)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_applies_defaults(store: ResearchTemplateRepository):
|
||||
row = await store.create(name="深度分析模板")
|
||||
assert row["name"] == "深度分析模板"
|
||||
assert row["material_source"] == "general"
|
||||
assert row["word_count"] == 800
|
||||
assert row["strict_mode"] is False
|
||||
assert row["article_type_key"] is None
|
||||
assert row["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_full_then_get_roundtrips(store: ResearchTemplateRepository):
|
||||
created = await store.create(
|
||||
name="舆情研究",
|
||||
description="围绕舆情的研究模板",
|
||||
article_type_key="analysis",
|
||||
outline="一、背景\n二、分析\n三、结论",
|
||||
material_source="notebook",
|
||||
word_count=1500,
|
||||
audience="决策者",
|
||||
strict_mode=True,
|
||||
topic="某品牌近期舆情",
|
||||
sort_order=2,
|
||||
)
|
||||
fetched = await store.get(created["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["article_type_key"] == "analysis"
|
||||
assert fetched["outline"].startswith("一、背景")
|
||||
assert fetched["material_source"] == "notebook"
|
||||
assert fetched["word_count"] == 1500
|
||||
assert fetched["audience"] == "决策者"
|
||||
assert fetched["strict_mode"] is True
|
||||
assert fetched["topic"] == "某品牌近期舆情"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orders_by_sort_order(store: ResearchTemplateRepository):
|
||||
await store.create(name="B", sort_order=2)
|
||||
await store.create(name="A", sort_order=1)
|
||||
rows = await store.list()
|
||||
assert [r["name"] for r in rows] == ["A", "B"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_partial_keeps_other_fields(store: ResearchTemplateRepository):
|
||||
created = await store.create(name="模板", word_count=800, strict_mode=False)
|
||||
updated = await store.update(created["id"], word_count=2000, strict_mode=True)
|
||||
assert updated is not None
|
||||
assert updated["word_count"] == 2000
|
||||
assert updated["strict_mode"] is True
|
||||
assert updated["name"] == "模板" # untouched
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_missing_returns_none(store: ResearchTemplateRepository):
|
||||
assert await store.update("nope", name="x") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete(store: ResearchTemplateRepository):
|
||||
created = await store.create(name="待删")
|
||||
assert await store.delete(created["id"]) is True
|
||||
assert await store.get(created["id"]) is None
|
||||
assert await store.delete(created["id"]) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_defaults_inserts_builtins_when_empty(store: ResearchTemplateRepository):
|
||||
inserted = await store.seed_defaults()
|
||||
assert inserted == 3
|
||||
rows = await store.list()
|
||||
names = [r["name"] for r in rows]
|
||||
assert names == ["网摘", "周报", "要讯"] # sort_order 0/1/2
|
||||
# 内置模板内容完整(范例质量)。
|
||||
by_name = {r["name"]: r for r in rows}
|
||||
assert by_name["网摘"]["article_type_key"] == "news"
|
||||
assert by_name["周报"]["strict_mode"] is True
|
||||
assert by_name["要讯"]["outline"]
|
||||
assert all(r["topic"] for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_defaults_is_idempotent(store: ResearchTemplateRepository):
|
||||
assert await store.seed_defaults() == 3
|
||||
# 已有模板 → 再次播种跳过,不重复、不覆盖。
|
||||
assert await store.seed_defaults() == 0
|
||||
assert len(await store.list()) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_defaults_skips_when_user_has_templates(store: ResearchTemplateRepository):
|
||||
await store.create(name="用户自建")
|
||||
assert await store.seed_defaults() == 0
|
||||
assert [r["name"] for r in await store.list()] == ["用户自建"]
|
||||
Reference in new issue
Block a user