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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user