feat(qa-center): 问答中心左侧栏改造(0616 需求 2-5)
- 多智能体会商:Step1 落地左栏「业务链(public 前3) + 分析记录」;点链=勾选(autoChainResult),第一步澄清后点「进入研讨」才按链执行
- zzJC/zzXD:独立模块路由 /page/strategy/qa/{zzjc,zzxd},常驻左栏(按 agent 标签分组 + 仅本模块会话的聊天记录),点智能体模块内切换不跳页,默认落地配置类型下首个智能体
- zzJC/zzXD admin 设置:左栏齿轮→单选类型(TDesign)配置面板+智能体预览;后端 system_settings.agent_modules 持久化(GET 开放/PUT admin)+ test_agent_modules
- 课题研究:左栏「研究模板(文章类型) + 笔记本」;模板点击预填表单,笔记本点击设为素材来源(selectedNotebookId 上提 WritingFormContext)
- 通用问答:全量模式历史侧栏默认展开
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8794c51f88
commit
f39cffcb9a
18 files changed
+1113
-41
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,128 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, FileText } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useArticleTypes } from "../../hooks/useArticleTypes";
|
||||
import { useWritingForm } from "../../contexts/WritingFormContext";
|
||||
|
||||
/**
|
||||
* 课题研究左栏(需求 0616 #5)。
|
||||
* - 上半「研究模板」= 文章类型(``useArticleTypes``);点击预填表单(设置文章类型 +
|
||||
* 默认字数 + 展开写作配置),沿用现有 articleType 设置逻辑。
|
||||
* - 下半「笔记本」= 我的空间(``useStudioNotebooks``);点击设为当前课题研究的素材来源
|
||||
* (materialSource='notebook' + 选中该本)。
|
||||
*/
|
||||
export function ResearchSidebar() {
|
||||
const { articleTypes, refresh } = useArticleTypes();
|
||||
const { notebooks } = useStudioNotebooks();
|
||||
const {
|
||||
articleType,
|
||||
setArticleType,
|
||||
setWordCountInput,
|
||||
setFormExpanded,
|
||||
materialSource,
|
||||
setMaterialSource,
|
||||
selectedNotebookId,
|
||||
setSelectedNotebookId,
|
||||
setSelectedDocumentIds,
|
||||
} = useWritingForm();
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleTemplateClick = (key: string, defaultWordCount: number) => {
|
||||
setArticleType(key);
|
||||
if (defaultWordCount > 0) setWordCountInput(String(defaultWordCount));
|
||||
setFormExpanded(true);
|
||||
};
|
||||
|
||||
const handleNotebookClick = (notebookId: number) => {
|
||||
setMaterialSource("notebook");
|
||||
setSelectedNotebookId(notebookId);
|
||||
setSelectedDocumentIds([]);
|
||||
setFormExpanded(true);
|
||||
};
|
||||
|
||||
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">
|
||||
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
研究模板
|
||||
</h2>
|
||||
{articleTypes.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无研究模板</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{articleTypes.map((t) => {
|
||||
const active = articleType === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => handleTemplateClick(t.key, t.default_word_count)}
|
||||
title={t.description ?? t.label}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground 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">{t.label}</span>
|
||||
{t.description && (
|
||||
<span className="text-muted-foreground/70 line-clamp-1 block text-[11px]">
|
||||
{t.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>
|
||||
{notebooks.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无笔记本</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{notebooks.map((nb) => {
|
||||
const active =
|
||||
materialSource === "notebook" && selectedNotebookId === nb.id;
|
||||
return (
|
||||
<button
|
||||
key={nb.id}
|
||||
type="button"
|
||||
onClick={() => handleNotebookClick(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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>;
|
||||
|
||||
@@ -93,6 +93,7 @@ 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 { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
|
||||
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
|
||||
import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext';
|
||||
@@ -227,11 +228,19 @@ export default function AIWritingPage({
|
||||
<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 />}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -208,9 +208,7 @@ export default function AgentChatPage() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/page/workspace/agents/${agent_id}/chats/new`,
|
||||
);
|
||||
router.push(`${routeBase}/agents/${agent_id}/chats/new`);
|
||||
}}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Cpu,
|
||||
Download,
|
||||
History,
|
||||
Layers,
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
Pencil,
|
||||
@@ -98,6 +99,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 +112,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 +779,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(() => {
|
||||
@@ -1557,6 +1588,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 +2090,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,6 +2137,9 @@ function LeftSidebar({
|
||||
onSelectStep2Run,
|
||||
seatFilter,
|
||||
setSeatFilter,
|
||||
selectedChainId,
|
||||
onSelectChain,
|
||||
onOpenDraft,
|
||||
}: LeftSidebarProps) {
|
||||
const isStep2 = currentStep === 2;
|
||||
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
|
||||
@@ -2223,28 +2266,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,
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user