Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	frontend-web/src/core/page-layout/sidebar-menu.ts
#	frontend-web/src/core/system-settings/api.ts
#	frontend-web/src/core/system-settings/hooks.ts
#	frontend-web/src/strategy-components/Header.tsx
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/app/gateway/routers/system_settings.py
#	offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py
#	offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql
This commit is contained in:
Administrator committed 2026-06-16 20:01:42 +08:00
commit b380575f16
103 files changed
+5915 -859

No files matched your search

+11 -1
View File
@@ -86,12 +86,22 @@ function AppProviders({ children }: { children: React.ReactNode }) {
);
}
/**
* 根路由 `/` → `/login` 重定向,**保留 query string**。
* 外部系统的任务深链落在 `#/?taskId=...&goPath=...`,裸 `<Navigate to="/login">` 会丢掉
* query,导致 LoginPage 读不到参数;这里把 search 一并带过去。
*/
function RootRedirect() {
const { search } = useLocation();
return <Navigate to={`/login${search}`} replace />;
}
export function App() {
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
@@ -27,6 +27,7 @@ import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { queryClient } from "@/components/query-client-provider";
import { Checkbox } from "@/components/ui/checkbox";
import Empty from "./empty";
import { NetworkActor } from "./network-actor";
@@ -693,169 +694,402 @@ const DocumentUploadPopupContent: FC<{
{/* A系统基因库 */}
{activeTab === "A" && (
<div className="bg-white rounded-lg">
<div className="custom-radio-group pt-[10px] ml-[10px]">
{searchTypes.map((option) => (
<div
key={option.value}
className={`custom-radio-btn ${searchType === option.value ? "active" : ""}`}
onClick={() => searchTypeChange(option.value)}
>
{option.label}
</div>
))}
</div>
{false && (
<div className="relative pt-[10px]">
<div className="relative rounded-lg border border-neutral-200 bg-white dark-theme-input ml-[10px] mr-[10px] mb-[10px] transition-colors hover:border-neutral-300">
<Textarea
placeholder="搜索资料..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={async (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
await searchAChange(searchType);
}
}}
className=" max-h-[160px] resize-none border-0 dark-theme-input bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
rows={1}
/>
{/* 左侧搜索图标 */}
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none dark-theme-search">
<Search className="h-4 w-4" strokeWidth={1.5} />
</div>
{/* 右侧搜索按钮 */}
<Button
size="sm"
onClick={async () =>
await searchAChange(searchType)
}
disabled={isSearching || !searchText.trim()}
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white dark-theme-search transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
>
{isSearching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<span>搜索</span>
)}
</Button>
{/* 左下角模式选择 */}
<div className="absolute left-2 bottom-1.5 flex items-center">
{/* <Select value={searchType} onValueChange={(value) => searchTypeChange(value)}>
<SelectTrigger className="h-6 w-auto min-w-[80px] gap-1.5 rounded border border-neutral-200 bg-white px-2 py-0 pr-1.5 text-[11px] font-medium text-neutral-600 hover:bg-neutral-50 data-[state=open]:bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent align="start" className="min-w-[120px] rounded-md border border-neutral-200 bg-white p-0.5 shadow-sm">
{searchTypes.map((mode) => {
return (
<SelectItem
key={mode.value}
value={mode.value}
className="rounded px-2 py-1.5 text-[11px] font-medium data-[highlighted]:bg-neutral-100"
>
<div className="flex items-center gap-1.5">
<span className="text-neutral-700">{mode.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select> */}
</div>
</div>
</div>
)}
<div className="custom-radio-group pt-[10px] ml-[10px]">
{" "}
{/* 外层容器,模拟 nz-radio-group */}
{searchTypes.map((option) => {
const isActive = searchType === option.value;
return (
<div
key={option.value}
className={`custom-radio-btn ${isActive ? "active" : ""
}`}
onClick={() => searchTypeChange(option.value)}
>
{option.label}
</div>
);
})}
</div>
{/* 批量操作栏 - 当有选中项时显示 */}
{selectedSearchSources.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
<span className="text-sm text-gray-600">
已选中 {selectedSearchSources.length} 项
</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
<Button
size="sm"
variant="outline"
onClick={handleBatchDelete}
className="h-8 px-3 text-sm"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除选中
</Button>
<Button
size="sm"
onClick={() => void handleImport()}
onClick={() => handleImport()}
disabled={isImporting}
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
>
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
{isImporting ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5 mr-1" />
)}
导入选中
</Button>
</div>
</div>
)}
{/* 如果没有选中项,显示普通导入按钮 */}
{isImporting && (
<div className="px-4 py-2 border-b border-gray-100">
<Button
onClick={() => handleImport()}
disabled={isImporting}
className="w-full h-8 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-1" />
)}
{isImporting ? "导入中..." : "导入全部"}
</Button>
</div>
)}
{listData.length === 0 && <Empty />}
{searchType === "human" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
{/* 上传文件显示 loading 状态 */}
{item.is_uploaded_file && item.processing_status === 'processing' && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
{listData.map((item: listDataItem, index: number) => {
return (
<div
key={index}
className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
>
<div className="w-[50px] mr-[10px]">
<img
src={item.imagePath || ""}
className="w-[50px] h-[50px]"
alt=""
/>
</div>
)}
{/* 上传文件完成状态标记 */}
{item.is_uploaded_file && item.processing_status === 'error' && (
<div className="absolute top-1 left-1">
<span className="text-xs text-red-500">处理失败</span>
<div className="flex-1 min-w-0">
<p
className="truncate hover:underline cursor-pointer"
onClick={() => linkToA(item.url)}
>
{item.name}
</p>
<p className="truncate text-[#909090]">
职业:{item.occupationData}
</p>
<p className="truncate text-[#909090]">
属地:{item.country}
</p>
</div>
)}
{item.is_uploaded_file && item.processing_status === 'success' && (
<div className="absolute top-1 left-1">
<span className="text-xs text-green-500">✓ 已完成</span>
<div className="absolute top-[5px] right-[10px]">
<Checkbox
checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div>
)}
<div className="w-[50px] mr-[10px]">
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
<p className="truncate text-[#909090]">职业:{item.occupationData}</p>
<p className="truncate text-[#909090]">属地:{item.country}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
);
})}
</div>
)}
{searchType === "organization" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div className="w-[50px] mr-[10px]">
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
{listData.map((item: listDataItem, index: number) => {
return (
<div
key={index}
className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
>
<div className="w-[50px] mr-[10px]">
<img
src={item.imagePath || ""}
className="w-[50px] h-[50px]"
alt=""
/>
</div>
<div className="flex-1 min-w-0">
<p
className="truncate hover:underline cursor-pointer"
onClick={() => linkToA(item.url)}
>
{item.name}
</p>
<p className="truncate text-[#909090]">
组织类别:{item.subtype}
</p>
<p className="truncate text-[#909090]">
简介:{item.text}
</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<Checkbox
checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
<p className="truncate text-[#909090]">组织类别:{item.subtype}</p>
<p className="truncate text-[#909090]">简介:{item.text}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
);
})}
</div>
)}
{searchType === "community" && (
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div>
<div className="truncate mb-[5px] hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</div>
<div className="flex items-center text-[#909090] mb-[5px]">
{item.nations && <span className="truncate max-w-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">{item.nations}</span>}
{item.labels && <span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">{item.labels}</span>}
{listData.map((item: listDataItem, index: number) => {
return (
<div
key={index}
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
>
<div>
<div
className="truncate mb-[5px] hover:underline cursor-pointer"
onClick={() => linkToA(item.url)}
>
{item.title}
</div>
<div className="flex items-center text-[#909090] mb-[5px]">
{item.nations && (
<span className="truncate max-width-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">
{item.nations}
</span>
)}
{item.labels && (
<span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">
{item.labels}
</span>
)}
</div>
<div className="flex justify-between mb-[5px]">
<span className="text-[#909090]">
群体规模:
<span className="text-[#cf1322]">
{item.total}
</span>
</span>
<span className="text-[#909090]">
对应实体数:
<span className="text-[#cf1322]">
{item.real_total}
</span>
</span>
</div>
<div>
<span className="text-[#909090]">
涉及平台:
<span className="text-[#222222]">
{item.platforms}
</span>
</span>
</div>
</div>
<div className="flex justify-between mb-[5px]">
<span className="text-[#909090]">群体规模:<span className="text-[#cf1322]">{item.total}</span></span>
<span className="text-[#909090]">对应实体数:<span className="text-[#cf1322]">{item.real_total}</span></span>
<div className="absolute top-[5px] right-[10px]">
<Checkbox
checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div>
<div><span className="text-[#909090]">涉及平台:<span className="text-[#222222]">{item.platforms}</span></span></div>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
);
})}
</div>
)}
{searchType === "issue" && (
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div>
<div className="truncate mb-[5px]">{item.title}</div>
<div className="flex justify-between mb-[5px]">
<span className="text-[#909090]">方向:<span className="text-[#222222]">{item.direction}</span></span>
<span className="text-[#909090]">类别:<span className="text-[#222222]">{item.issue_type}</span></span>
{listData.map((item: listDataItem, index: number) => {
return (
<div
key={index}
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
>
<div>
<div className="truncate mb-[5px]">
{item.title}
</div>
<div className="flex justify-between mb-[5px]">
<span className="text-[#909090]">
方向:
<span className="text-[#222222]">
{item.direction}
</span>
</span>
<span className="text-[#909090]">
类别:
<span className="text-[#222222]">
{item.issue_type}
</span>
</span>
</div>
</div>
<div className="absolute top-[5px] right-[10px]">
<Checkbox
checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
);
})}
</div>
)}
{searchType === "event" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div className="flex-1 min-w-0">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
<p className="truncate text-[#909090]">首发新闻:{item.site_name}</p>
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time}</p>
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、")}</p>
{listData.map((item: listDataItem, index: number) => {
return (
<div
key={index}
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
>
<div className="flex-1 min-w-0">
<p
className="truncate hover:underline cursor-pointer"
onClick={() => linkToA(item.url)}
>
{item.title}
</p>
<p className="truncate text-[#909090]">
首发新闻:{item.site_name}
</p>
<p className="truncate text-[#909090]">
事件时间:{item.first_publish_time}
</p>
<p className="truncate text-[#909090]">
事件时间:{item.keywords?.join("、")}
</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<Checkbox
checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
);
})}
</div>
)}
{searchType === "threat" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div className="flex-1 min-w-0 pr-[25px]">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
<p className="truncate text-[#909090]">发布平台:{item.site_name || "暂无数据"}</p>
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time || "暂无数据"}</p>
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、") || "暂无数据"}</p>
{listData.map((item: listDataItem, index: number) => {
return (
<div
key={index}
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
>
<div className="flex-1 min-w-0 pr-[25px]">
<p
className="truncate hover:underline cursor-pointer"
onClick={() => linkToA(item.url)}
>
{item.title}
</p>
<p className="truncate text-[#909090]">
发布平台:{item.site_name || "暂无数据"}
</p>
<p className="truncate text-[#909090]">
事件时间:
{item.first_publish_time || "暂无数据"}
</p>
<p className="truncate text-[#909090]">
关键词:
{item.keywords?.join("、") || "暂无数据"}
</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<Checkbox
checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
);
})}
</div>
)}
</div>
@@ -54,7 +54,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
};
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
@@ -153,7 +153,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
inWujieEnv
? item.id === activeTopId
: item.path
? pathname === item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item);
const handleSidebarItemClick = (item: MenuItem) => {
if (inWujieEnv) {
+5 -3
View File
@@ -42,7 +42,7 @@ type PageSidebarProps = {
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
const hasActiveChild = (item: MenuItem, pathname: string): boolean => {
if (item.path && pathname === item.path) {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
@@ -116,7 +116,9 @@ function SecondaryNavSidebar() {
const secondaryItems = activeTopItem?.children ?? [];
const isItemActive = (item: MenuItem) =>
item.path ? pathname === item.path : hasActiveChild(item, pathname);
item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item, pathname);
const handleItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
@@ -210,7 +212,7 @@ function SecondaryNavSidebar() {
{thirdItems.map((child) => {
const ChildIcon = child.icon;
const childActive = child.path
? pathname === child.path
? pathname === child.path || pathname.startsWith(child.path + "/")
: hasActiveChild(child, pathname);
return (
<button
@@ -0,0 +1,46 @@
"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CheckboxProps
extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange" | "checked" | "type"
> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
/**
* 轻量级 Checkbox(不依赖 @radix-ui/react-checkbox,适配离线部署)。
* API 与 Radix/shadcn 对齐:受控 `checked` + `onCheckedChange(next)`。
*/
export const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
return (
<button
ref={ref}
type="button"
role="checkbox"
aria-checked={checked}
disabled={disabled}
data-state={checked ? "checked" : "unchecked"}
onClick={() => onCheckedChange?.(!checked)}
className={cn(
"peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-sm outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
checked && "border-primary bg-primary text-primary-foreground",
className,
)}
{...props}
>
{checked && <Check className="size-3.5" strokeWidth={3} />}
</button>
);
},
);
Checkbox.displayName = "Checkbox";
@@ -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,127 @@
"use client";
import { Bot, ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
/** 常用智能体最多展示个数(需求 1.2 ②)。 */
const MAX_FREQUENT = 3;
/** 「更多」跳转目标:智能体管理页(需求 1.3)。 */
const MORE_HREF = "/page/strategy/agents";
/** 打开某个智能体主页(其对话落地页,thread_id=new = 新会话)。 */
function agentHomeHref(agentId: string): string {
return `/page/strategy/agents/${agentId}/chats/new`;
}
/**
* 「常用智能体」侧栏区块(通用问答左栏,全量模式)。
*
* 级联兜底取数(需求 1.2 ② / 1.3)——按优先级补足到最多 3 个、去重:
* 1. 置顶智能体(``is_pinned``,可来自「我的」或「广场」);
* 2. 不足 3 个 → 用「我的」智能体(``scope="mine"``)补足;
* 3. 仍不足 → 用「广场」默认推荐(``scope="square"``)补足。
*
* 取不到任何智能体时整块隐藏(与 ``RecentChatList`` 的空态约定一致)。
*/
export function FrequentAgentsList() {
const pathname = usePathname();
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);
};
// 1. 置顶组优先(够 3 个则下面我的/广场都不再补)。
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
// 2. 我的智能体补足。
mine.forEach(add);
// 3. 广场默认推荐补足。
square.forEach(add);
return out;
}, [mine, square]);
if (frequent.length === 0) {
return null;
}
return (
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
常用智能体
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{frequent.map((agent) => {
const href = agentHomeHref(agent.id);
const isActive = pathname === href;
return (
<SidebarMenuItem key={agent.id}>
<SidebarMenuButton
isActive={isActive}
asChild
className={
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none"
}
>
<Link
className="flex w-full items-center gap-2 whitespace-nowrap"
href={href}
>
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{desensitize(agent.name)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
>
<Link
className="flex w-full items-center justify-between gap-1"
href={MORE_HREF}
>
<span>更多</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -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>
);
}
@@ -1,7 +1,8 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import {
Conversation,
@@ -53,6 +54,38 @@ import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
function ScrollToBottomOnUserMessage({
threadId,
messages,
}: {
threadId: string;
messages: Message[];
}) {
const { scrollToBottom } = useStickToBottomContext();
const prevHumanCountRef = useRef<number | null>(null);
useEffect(() => {
prevHumanCountRef.current = null;
}, [threadId]);
useEffect(() => {
const humanCount = messages.reduce(
(count, message) => count + (message.type === "human" ? 1 : 0),
0,
);
const prev = prevHumanCountRef.current;
prevHumanCountRef.current = humanCount;
if (prev !== null && humanCount > prev) {
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToBottom());
});
}
}, [messages, scrollToBottom]);
return null;
}
export function MessageList({
className,
contentClassName,
@@ -270,6 +303,7 @@ export function MessageList({
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
@@ -2,6 +2,7 @@
import {
ChevronDown,
ChevronRight,
Download,
FileJson,
FileText,
@@ -74,7 +75,17 @@ import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
export function RecentChatList({ compact = false }: { compact?: boolean }) {
export function RecentChatList({
compact = false,
limit,
moreHref,
}: {
compact?: boolean;
/** 最多展示的会话条数;省略则不限制(需求 1.3:通用问答最多 10 条)。 */
limit?: number;
/** 「更多」入口跳转目标;省略则不展示「更多」行。 */
moreHref?: string;
}) {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
@@ -190,6 +201,8 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
if (visibleThreads.length === 0) {
return null;
}
const shownThreads =
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
return (
<>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
@@ -225,7 +238,7 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => {
{shownThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
@@ -368,6 +381,22 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
</SidebarMenuItem>
);
})}
{moreHref && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
>
<Link
className="flex w-full items-center justify-between gap-1"
href={moreHref}
>
<span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
@@ -167,7 +167,7 @@ export function WorkspaceNavMenu() {
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin && (
{isAdmin ? (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
@@ -176,6 +176,15 @@ export function WorkspaceNavMenu() {
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
@@ -10,7 +10,9 @@ import {
} from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list";
@@ -25,7 +27,12 @@ export function WorkspaceSidebar({
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
@@ -44,7 +51,7 @@ export function WorkspaceSidebar({
variant="sidebar"
collapsible="none"
className={cn(
"h-full w-(--sidebar-width)",
"h-full w-(--sidebar-width) border-r border-border",
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
@@ -52,8 +59,16 @@ export function WorkspaceSidebar({
{...props}
>
<SidebarContent>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
{isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar />
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent>
<SidebarRail />
</Sidebar>
+175
View File
@@ -0,0 +1,175 @@
/**
* 外部系统跳转登录时的「任务深链」:登录 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)
};
/**
* 业务码 → 「任务管理」布局下的路由前缀(与 sidebar-menu.ts 的 COH 中任务菜单路径一致)。
* 会商 / 智能体问答都挂在此前缀下,保证侧边栏停留在 任务管理 → COH中任务 → 对应业务 高亮。
*/
const BUSINESS_TASK_BASE: Record<string, string> = {
"3Q": "/page/strategy/task-management/plan-task-sentiment",
"6BF": "/page/strategy/task-management/plan-task/plan-task-fx",
"7BF": "/page/strategy/task-management/plan-task/plan-task-action",
};
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;
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
if (!taskBase) return null;
if (parsed.roundtable) {
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。
return {
path: `${taskBase}/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: `${taskBase}/agent/${agentId}/chats/new` };
}
+109 -92
View File
@@ -44,6 +44,8 @@ export type MenuItem = {
label: string;
icon: ElementType;
path?: string;
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
@@ -51,7 +53,7 @@ export type MenuItem = {
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答中心",
label: "问答管理",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
@@ -64,11 +66,11 @@ export const sidebarMenuItems: MenuItem[] = [
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
label: "zzJC支持",
label: "JC支持",
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: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
],
},
@@ -82,18 +84,18 @@ export const sidebarMenuItems: MenuItem[] = [
label: "XK中任务",
icon: ClipboardList,
children: [
{
id: "xk-task-jc",
label: "任务JC",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-jc",
},
{
id: "xk-task-dispatch",
label: "任务调度",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-dispatch",
},
// {
// id: "xk-task-jc",
// label: "任务JC",
// icon: ListTodo,
// path: "/page/strategy/task-management/xk-task-jc",
// },
// {
// id: "xk-task-dispatch",
// label: "任务调度",
// icon: ListTodo,
// path: "/page/strategy/task-management/xk-task-dispatch",
// },
],
},
{
@@ -101,24 +103,24 @@ export const sidebarMenuItems: MenuItem[] = [
label: "COH中任务",
icon: Activity,
children: [
{
id: "coh-task-3qfx",
label: "3QFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task-sentiment",
},
{
id: "coh-task-fx",
label: "任务FX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx",
},
{
id: "coh-task-xdfx",
label: "XDFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-action",
},
// {
// id: "coh-task-3qfx",
// label: "3QFX",
// icon: ListTodo,
// path: "/page/strategy/task-management/plan-task-sentiment",
// },
// {
// id: "coh-task-fx",
// label: "任务FX",
// icon: ListTodo,
// path: "/page/strategy/task-management/plan-task/plan-task-fx",
// },
// {
// id: "coh-task-xdfx",
// label: "XDFX",
// icon: ListTodo,
// path: "/page/strategy/task-management/plan-task/plan-task-action",
// },
],
},
{
@@ -126,24 +128,24 @@ export const sidebarMenuItems: MenuItem[] = [
label: "PG中任务",
icon: Gauge,
children: [
{
id: "pg-task-pg",
label: "任务PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-task-pg",
},
{
id: "pg-task-xdpg",
label: "XDPG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-action-pg",
},
{
id: "pg-task-info-pg",
label: "信息PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-info-pg",
},
// {
// id: "pg-task-pg",
// label: "任务PG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-task-pg",
// },
// {
// id: "pg-task-xdpg",
// label: "XDPG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-action-pg",
// },
// {
// id: "pg-task-info-pg",
// label: "信息PG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-info-pg",
// },
],
},
{
@@ -151,24 +153,24 @@ export const sidebarMenuItems: MenuItem[] = [
label: "TY中任务",
icon: Radar,
children: [
{
id: "ty-task-xdty",
label: "XDTY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-mechanism",
},
{
id: "ty-task-plan",
label: "方案TY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-plan",
},
{
id: "ty-task-xxty",
label: "XXTY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-zsty",
},
// {
// id: "ty-task-xdty",
// label: "XDTY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-mechanism",
// },
// {
// id: "ty-task-plan",
// label: "方案TY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-plan",
// },
// {
// id: "ty-task-xxty",
// label: "XXTY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-zsty",
// },
],
},
],
@@ -198,6 +200,13 @@ export const sidebarMenuItems: MenuItem[] = [
label: "综合管理",
icon: Settings,
children: [
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
userPath: "/page/strategy/user-leaderboard",
},
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
@@ -268,12 +277,12 @@ export const sidebarMenuItems: MenuItem[] = [
label: "资源管理",
icon: Database,
children: [
{
id: "resource-network-xwt",
label: "网络xwt资源管理",
icon: Network,
path: "/page/strategy/resource-management/resource-network-actor",
},
// {
// id: "resource-network-xwt",
// label: "网络xwt资源管理",
// icon: Network,
// path: "/page/strategy/resource-management/resource-network-actor",
// },
{
id: "resource-knowledge-native",
label: "问答梳理",
@@ -286,12 +295,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Library,
path: "/page/strategy/resource-management/resource-knowledge-base",
},
{
id: "resource-evaluation-index",
label: "作战资源管理",
icon: FileBox,
path: "/page/strategy/resource-management/resource-evaluation-index",
},
// {
// id: "resource-evaluation-index",
// label: "作战资源管理",
// icon: FileBox,
// path: "/page/strategy/resource-management/resource-evaluation-index",
// },
],
},
];
@@ -306,9 +315,9 @@ export const defaultExpandedIds: ReadonlySet<string> = new Set([
"comprehensive-management",
]);
/** 该菜单项自身或任意后代的 path 是否等于当前路由 */
/** 该菜单项自身或任意后代的 path 是否匹配当前路由(支持子路径,如 /knowledge/:id) */
export function menuItemContainsPath(item: MenuItem, pathname: string): boolean {
if (item.path && item.path === pathname) {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false;
@@ -316,7 +325,8 @@ export function menuItemContainsPath(item: MenuItem, pathname: string): boolean
/**
* 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。
* 浅色侧边栏据此在二级右侧展开三级列;无三级则返回空数组(侧边栏保持窄栏)。
* 浅色侧边栏据此在二级右侧展开三级列;深色顶栏据此渲染三级横条。
* 全量模式:三级仅一项时不返回(无选择必要,侧边栏保持窄栏 / 不渲染横条)。
*/
export function getActiveThirdLevelItems(
items: MenuItem[],
@@ -328,16 +338,23 @@ export function getActiveThirdLevelItems(
const activeSecond = secondary.find(
(item) => (item.children?.length ?? 0) > 0 && menuItemContainsPath(item, pathname),
);
return activeSecond?.children ?? [];
const third = activeSecond?.children ?? [];
return third.length <= 1 ? [] : third;
}
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项及其子项 */
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项;``userPath`` 替换 ``path``。 */
export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
.map((item) =>
item.children
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
: item,
);
.map((item) => {
const children = item.children
? filterMenuItemsByRole(item.children, isAdmin)
: undefined;
const base = children ? { ...item, children } : item;
if (!isAdmin && base.userPath) {
const { userPath: _userPath, ...rest } = base;
return { ...rest, path: base.userPath };
}
return base;
});
}
@@ -291,6 +291,45 @@ export async function updateSkillCompressionSettings(
return (await response.json()) as SkillCompressionSettings;
}
/** 「按类型聚合」模块(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,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSettings } from "@/core/settings";
import {
loadAgentModuleConfig,
loadAppearanceSettings,
loadCitationDisplaySettings,
loadLeaderboardSettings,
@@ -10,6 +11,7 @@ import {
loadPromptPrefixSettings,
loadPublishSquares,
loadSkillCompressionSettings,
updateAgentModuleConfig,
updateAppearanceSettings,
updateCitationDisplaySettings,
updateLeaderboardSettings,
@@ -169,6 +171,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,
+23
View File
@@ -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>
);
}
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search } from 'lucide-react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search, AlertTriangle, Shuffle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SkillSearchStep } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition';
@@ -16,6 +16,8 @@ const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
select_skills: Sparkles,
invoke_skill: Zap,
skill_result: CheckCircle2,
skill_error: AlertTriangle,
fallback: Shuffle,
stop_early: CircleStop,
};
@@ -25,6 +27,8 @@ const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
select_skills: '筛选相关技能',
invoke_skill: '调用技能',
skill_result: '检索结果',
skill_error: '技能调用失败',
fallback: '改用其他技能',
stop_early: '提前停止',
};
@@ -71,6 +75,9 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
const label = STEP_LABEL[step.type] ?? step.type;
const isStopEarly = step.type === 'stop_early';
const isResult = step.type === 'skill_result';
// 容错:技能调用失败标红,回退其它技能标琥珀,提示用户"出错了但已自动改用其他技能"
const isError = step.type === 'skill_error';
const isFallback = step.type === 'fallback';
const isActive = idx === activeIdx;
return (
<li key={idx} className="flex items-start gap-2 text-[11px]">
@@ -79,11 +86,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
isActive
? 'bg-violet-500 text-white'
: isStopEarly
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: isResult
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
: isError
? 'bg-rose-100 dark:bg-rose-900/60 text-rose-600 dark:text-rose-300'
: isStopEarly || isFallback
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: isResult
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
)}
>
{isActive ? <Loader2 className="size-2.5 animate-spin" /> : idx + 1}
@@ -92,11 +101,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
<div
className={cn(
'flex items-center gap-1 font-medium',
isStopEarly
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
isError
? 'text-rose-700 dark:text-rose-300'
: isStopEarly || isFallback
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
)}
>
<Icon className="size-3 shrink-0" />
@@ -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';
@@ -210,6 +217,81 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
);
}
/**
* 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态:
* 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。
*/
function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
// 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容
// (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。
const [rightView, setRightView] = useState<RightView>(null);
const overlayActive = rightView != null;
const viewingNotebookId = rightView?.kind === 'notebook' ? rightView.id : null;
// 进入写作(开始生成 / 查看历史)后收起研究模板侧栏;嵌入态(studioToken)始终不展示。
const showSidebar = !studioToken && state.status === 'idle' && !isHistoryMode;
return (
<div className="flex-1 min-h-0 overflow-hidden flex">
{/* 需求 0616 #5:课题研究左栏(研究模板 + 笔记本)。进入写作后收起。 */}
{showSidebar && (
<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>
);
}
export default function AIWritingPage({
hideAdvancedFields = false,
initialNotebookId,
@@ -227,12 +309,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>
<AIWritingBody
hideAdvancedFields={hideAdvancedFields}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
/>
</div>
</TooltipProvider>
</WritingFormProvider>
@@ -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>
);
@@ -20,6 +20,8 @@ export interface AIMaterial {
// select_skills:LLM 按相关度排序的候选
// invoke_skill:开始调用某个技能去检索
// skill_result:某个技能检索返回了 N 条素材
// skill_error:某个技能调用失败(超时/报错),将改用其他技能(容错)
// fallback:配置技能全部失败,回退到其他技能继续搜集(容错)
// stop_early:累计素材达到上限,停止后续技能
export interface SkillSearchStep {
type:
@@ -28,6 +30,8 @@ export interface SkillSearchStep {
| "select_skills"
| "invoke_skill"
| "skill_result"
| "skill_error"
| "fallback"
| "stop_early";
message: string;
skillNames?: string[];
+24 -4
View File
@@ -31,15 +31,31 @@ import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
export default function AgentChatPage() {
export default function AgentChatPage({
embeddedAgentId,
chatBasePath,
}: {
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
embeddedAgentId?: string;
/** 智能体对话所在的路由前缀(到 `/chats` 为止,不含 thread_id)。
* 默认 `${routeBase}/agents/${agent_id}/chats`;任务深链把对话嵌进「任务管理」布局时,
* 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`,
* 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */
chatBasePath?: 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;
// 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。
const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`;
// 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
@@ -92,7 +108,7 @@ export default function AgentChatPage() {
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/agents/${agent_id}/chats/${createdThreadId}`,
`${window.location.pathname}${window.location.search}#${agentChatsBase}/${createdThreadId}`,
);
},
onFinish: (state) => {
@@ -208,8 +224,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}`
: `${agentChatsBase}/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 ── */}
+12 -4
View File
@@ -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();
+152 -28
View File
@@ -1,10 +1,18 @@
"use client";
import {
BookMarkedIcon,
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
GraduationCapIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { useArtifacts } from "@/components/workspace/artifacts";
import { AgentSelector } from "@/components/workspace/agents/agent-selector";
import {
ChatBox,
useSpecificChatMode,
@@ -12,18 +20,23 @@ import {
} from "@/components/workspace/chats";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { TodoList } from "@/components/workspace/todo-list";
import { Button } from "@/components/ui/button";
import { useAgentSelector } from "@/core/agents";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useRouteBase } from "@/core/route-base";
import { useThreadSettings } from "@/core/settings";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import {
getLastAgentId,
isThreadRagModeLocked,
saveLastAgentId,
useLocalSettings,
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
@@ -81,16 +94,16 @@ export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
const [settings] = useThreadSettings(threadId);
const [settings, setSettings] = useThreadSettings(threadId);
const [localSettings, setLocalSettings] = useLocalSettings();
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector();
const pendingRequestIdRef = useRef<string | null>(null);
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
@@ -124,11 +137,19 @@ export default function IframeChatPage() {
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedAgentId(null);
setSelectedAgentId(getLastAgentId());
}
}, [isNewThread, threadId]);
useEffect(() => {
if (selectorLoading || !selectedAgentId) {
return;
}
if (!selectorAgents.some((agent) => agent.id === selectedAgentId)) {
setSelectedAgentId(null);
}
}, [selectedAgentId, selectorAgents, selectorLoading]);
const { showNotification } = useNotification();
const [thread, sendMessage, isUploading] = useThreadStream({
@@ -207,20 +228,12 @@ export default function IframeChatPage() {
return;
}
const shouldApplyPrefix =
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
promptPrefixSettings?.enabled === true;
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
const extraContext: Record<string, unknown> = {};
if (shouldApplyPrefix) {
extraContext.prompt_prefix_enabled = true;
}
if (settings.context.writing_mode && writingArtifactPath) {
extraContext.writing_artifact_path = writingArtifactPath;
}
@@ -238,8 +251,6 @@ export default function IframeChatPage() {
[
hasStartedConversation,
isNewThread,
promptPrefixOn,
promptPrefixSettings,
router,
selectedAgentId,
sendMessage,
@@ -257,6 +268,45 @@ export default function IframeChatPage() {
[sendMessage, threadId],
);
const handleAgentChange = useCallback((agentId: string | null) => {
setSelectedAgentId(agentId);
saveLastAgentId(agentId);
}, []);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled =
localSettings.display.ragModeEnabled || ragModeLocked;
const proModeEnabled = settings.context.mode === "pro";
const toggleWritingMode = useCallback(() => {
setSettings("context", {
writing_mode: !settings.context.writing_mode,
});
}, [setSettings, settings.context.writing_mode]);
const toggleMemoryInjection = useCallback(() => {
setSettings("context", {
memory_injection_enabled:
!(settings.context.memory_injection_enabled ?? true),
});
}, [setSettings, settings.context.memory_injection_enabled]);
const toggleRagMode = useCallback(() => {
if (ragModeLocked) {
return;
}
setLocalSettings("display", {
ragModeEnabled: !ragModeEnabled,
});
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const toggleProMode = useCallback(() => {
setSettings("context", {
mode: proModeEnabled ? "flash" : "pro",
reasoning_effort: proModeEnabled ? "minimal" : "medium",
});
}, [proModeEnabled, setSettings]);
const handleInterrupt = useCallback(
async (message: BrainyChatInterruptMessage) => {
const targetRequestId = message.payload?.requestId?.trim();
@@ -318,11 +368,47 @@ export default function IframeChatPage() {
};
}, [handleInterrupt, handleSubmit]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
const bottomBarHeightClass = "pb-28";
const modeButtons = [
{
key: "writing",
label: "写作",
icon: FileTextIcon,
active: !!settings.context.writing_mode,
onClick: toggleWritingMode,
activeClassName:
"border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100",
},
{
key: "memory",
label: "记忆",
icon: BrainCircuitIcon,
active: settings.context.memory_injection_enabled ?? true,
onClick: toggleMemoryInjection,
activeClassName:
"border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100",
},
{
key: "references",
label: "参考文献",
icon: BookMarkedIcon,
active: ragModeEnabled,
onClick: toggleRagMode,
disabled: ragModeLocked,
activeClassName:
"border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100",
},
{
key: "pro",
label: "专业问答",
icon: GraduationCapIcon,
active: proModeEnabled,
onClick: toggleProMode,
activeClassName:
"border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100",
},
] as const;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
@@ -350,24 +436,30 @@ export default function IframeChatPage() {
</header>
*/}
<main className="flex min-h-0 max-w-full grow flex-col overflow-hidden">
<div className="flex size-full min-w-0">
<div className={cn("flex size-full min-w-0", !isLandingState && bottomBarHeightClass)}>
{!isLandingState && (
<MessageList
className="size-full"
contentClassName="mx-0 max-w-full px-3 sm:px-4"
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
paddingBottom={96}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
)}
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"absolute inset-x-0 z-30 flex justify-center px-4",
isLandingState
? "top-0 bottom-24 items-center"
: "bottom-24",
)}
>
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-md)"
: "max-w-full",
@@ -387,6 +479,13 @@ export default function IframeChatPage() {
{isLandingState && mounted && (
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
<div className="text-2xl font-medium">你好</div>
<div className="mt-4">
<AgentSelector
selectedAgentId={selectedAgentId}
onAgentChange={handleAgentChange}
disabled={isUploading}
/>
</div>
</div>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
@@ -396,6 +495,31 @@ export default function IframeChatPage() {
)}
</div>
</div>
<div className="absolute right-0 bottom-0 left-0 z-40 border-t bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-2 px-4 py-4">
{modeButtons.map((button) => {
const Icon = button.icon;
return (
<Button
key={button.key}
type="button"
variant="outline"
disabled={button.disabled || isUploading}
onClick={button.onClick}
className={cn(
"gap-1.5 rounded-full border border-transparent bg-muted/40 text-muted-foreground shadow-none hover:bg-muted hover:text-foreground",
button.active && button.activeClassName,
button.disabled && "cursor-not-allowed opacity-80",
)}
>
{button.active && <CheckIcon className="size-3.5" />}
<Icon className="size-3.5" />
<span className="text-xs font-normal">{button.label}</span>
</Button>
);
})}
</div>
</div>
</main>
</div>
</ChatBox>
@@ -133,6 +133,8 @@ export default function LightAppManagementPage() {
const [form, setForm] = useState<FormState>(emptyForm);
const [activeParamPopover, setActiveParamPopover] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
const [categoryFilter, setCategoryFilter] = useState<"all" | LightAppType>("all");
const [mountFilter, setMountFilter] = useState<string>("");
const saving = actions.create.isPending || actions.update.isPending;
@@ -220,7 +222,22 @@ export default function LightAppManagementPage() {
toast.success("已复制地址");
};
const tableApps = useMemo(() => apps, [apps]);
const selectCategory = (cat: "all" | LightAppType) => {
setCategoryFilter(cat);
if (cat !== "iframe") setMountFilter("");
};
const tableApps = useMemo(
() =>
apps.filter((app) => {
if (categoryFilter !== "all" && app.appType !== categoryFilter) return false;
if (categoryFilter === "iframe" && mountFilter && app.mountLocation !== mountFilter) {
return false;
}
return true;
}),
[apps, categoryFilter, mountFilter],
);
return (
<div className="light-app-management-page h-full overflow-y-auto bg-neutral-50 dark:bg-[#0C1836] dark:text-[#C5CFE0]">
@@ -252,7 +269,7 @@ export default function LightAppManagementPage() {
</button>
</div>
<div className="lam-toolbar flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row">
<div className="lam-toolbar flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row md:flex-wrap">
<div className="relative w-full flex-1">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
<input
@@ -272,6 +289,41 @@ export default function LightAppManagementPage() {
)}
</div>
<div className="lam-cat-toggle flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
{(
[
{ value: "all", label: "全部" },
{ value: "window_open", label: "新页跳转" },
{ value: "iframe", label: "iframe 嵌套" },
] as const
).map((c) => (
<button
key={c.value}
onClick={() => selectCategory(c.value)}
className={`lam-cat-toggle-btn cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
categoryFilter === c.value
? "is-active bg-white text-[#F5222D] shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
{c.label}
</button>
))}
</div>
{categoryFilter === "iframe" && (
<div className="lam-mount-filter w-full shrink-0 animate-in fade-in self-end md:w-[180px] md:self-auto">
<Select
className="w-full"
value={mountFilter || undefined}
placeholder="按挂载菜单筛选"
clearable
options={MOUNT_OPTIONS}
onChange={(v) => setMountFilter((v as string) ?? "")}
/>
</div>
)}
<div className="lam-view-toggle flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
<button
onClick={() => setViewMode("table")}
@@ -650,7 +702,7 @@ export default function LightAppManagementPage() {
onClick={() => setDialogOpen(false)}
/>
<div className="lam-dialog relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="lam-dialog relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[900px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="lam-dialog-header flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
<div className="flex items-center space-x-2.5">
<span className="lam-dialog-icon rounded-[5px] bg-rose-50 p-1.5 text-[#A8071A]">
+34
View File
@@ -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 (
+3
View File
@@ -17,6 +17,7 @@ import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
import RoundtableRoutes from "./RoundtableRoutes";
import NotebookRoutes from "./NotebookRoutes";
import { RoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
@@ -43,6 +44,7 @@ function PageLayout() {
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
// 下容器不占满整屏时,高度才能正确收缩而非锁死整屏。
return (
<RoundtableJobNotifier>
<div className="h-full flex flex-col bg-muted/20">
{pageSidebarAllowed && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden">
@@ -97,6 +99,7 @@ function PageLayout() {
</main>
</div>
</div>
</RoundtableJobNotifier>
);
}
+68 -1
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar";
@@ -19,12 +19,15 @@ 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";
import ChatsPage from "./ChatsPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
import PublicLeaderboardPage from "./PublicLeaderboardPage";
import LightAppIframeView from "./LightAppIframeView";
import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage";
@@ -48,6 +51,20 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes";
/**
* 任务深链:把智能体问答嵌进「任务管理」布局。读路由参数 agent_id,给 AgentChatPage
* 传入以 taskBase 为前缀的对话路由(开新会话 / URL 改写都停留在任务管理上下文,
* 侧边栏保持对应业务高亮)。
*/
function TaskEmbeddedAgentChat({ taskBase }: { taskBase: string }) {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} />
</ChatRuntime>
);
}
export default function StrategyRoutes() {
return (
<RouteBaseProvider value="/page/strategy">
@@ -75,6 +92,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
@@ -107,6 +128,21 @@ export default function StrategyRoutes() {
/>
}
/>
{/* 任务深链(3qfx / 3qfx-A):把智能体问答 / 会商嵌进 3QFX 业务上下文。 */}
<Route
path="plan-task-sentiment/roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route
path="plan-task-sentiment/agent/:agent_id/chats/:thread_id"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task-sentiment" />
}
/>
<Route
path="plan-task/plan-task-fx"
element={
@@ -117,6 +153,21 @@ export default function StrategyRoutes() {
/>
}
/>
{/* 任务深链(rwfx / rwfx-A):嵌进 任务FX 业务上下文。 */}
<Route
path="plan-task/plan-task-fx/roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route
path="plan-task/plan-task-fx/agent/:agent_id/chats/:thread_id"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
}
/>
<Route
path="plan-task/plan-task-action"
element={
@@ -127,6 +178,21 @@ export default function StrategyRoutes() {
/>
}
/>
{/* 任务深链(xdfx / xdfx-A):嵌进 XDFX 业务上下文。 */}
<Route
path="plan-task/plan-task-action/roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route
path="plan-task/plan-task-action/agent/:agent_id/chats/:thread_id"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-action" />
}
/>
<Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} />
<Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} />
<Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} />
@@ -254,6 +320,7 @@ export default function StrategyRoutes() {
)
}
/>
<Route path="user-leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="admin/user-leaderboard"
element={
@@ -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();
+33 -8
View File
@@ -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>
+29 -6
View File
@@ -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';
@@ -72,6 +72,7 @@ export function WorkspaceLayout({
workspaceSidebarHidden = false,
headerHidden = false,
compactWorkspaceSidebar = false,
defaultHistorySidebarOpen,
wrapperOverflowHidden = false,
insetOverflowHidden = false,
pageSidebarDark = false,
@@ -82,10 +83,16 @@ export function WorkspaceLayout({
workspaceSidebarHidden?: boolean;
headerHidden?: boolean;
compactWorkspaceSidebar?: boolean;
/** 全量模式下通用问答等页:聊天记录侧栏默认展开;未指定时紧凑模式仍默认收起。 */
defaultHistorySidebarOpen?: boolean;
wrapperOverflowHidden?: boolean;
insetOverflowHidden?: boolean;
pageSidebarDark?: boolean;
}) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const isGeneralQa = useLocation().pathname.startsWith("/page/workspace/chats");
const pageSidebarWidth = pageSidebarHidden
? '0rem'
: pageSidebarDark
@@ -112,8 +119,9 @@ export function WorkspaceLayout({
}
key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed}
// 紧凑模式下历史侧栏默认收起(聊天区更宽),由聊天页右上角图标钮展开
defaultOpen={!compactWorkspaceSidebar}
defaultOpen={
defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar)
}
>
{!workspaceSidebarHidden && (
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
@@ -139,6 +147,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,
}: {
@@ -155,9 +178,9 @@ function NewChatEntryRoute({
}
return (
<WorkspaceLayout {...layoutProps}>
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
<NewChatContent />
</ChatRuntime>
</WorkspaceLayout>
);
@@ -203,7 +226,7 @@ export default function WorkspaceRoutes({
<Route
path="chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
@@ -65,22 +65,26 @@
outline: none;
}
.dark .light-app-management-page .lam-view-toggle {
.dark .light-app-management-page .lam-view-toggle,
.dark .light-app-management-page .lam-cat-toggle {
background: var(--color-bg-page);
border-color: var(--color-border);
}
.dark .light-app-management-page .lam-view-toggle-btn.is-active {
.dark .light-app-management-page .lam-view-toggle-btn.is-active,
.dark .light-app-management-page .lam-cat-toggle-btn.is-active {
background: var(--color-bg-card);
color: var(--color-link);
box-shadow: var(--shadow-1);
}
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active) {
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active),
.dark .light-app-management-page .lam-cat-toggle-btn:not(.is-active) {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active):hover {
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active):hover,
.dark .light-app-management-page .lam-cat-toggle-btn:not(.is-active):hover {
color: var(--color-text);
}
@@ -592,7 +596,8 @@
}
/* ── 视图切换激活态 ──────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-view-toggle-btn.is-active {
[data-theme="lightblue"] .light-app-management-page .lam-view-toggle-btn.is-active,
[data-theme="lightblue"] .light-app-management-page .lam-cat-toggle-btn.is-active {
color: #0062D9;
}
@@ -14,6 +14,7 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import type { SeatMode } from "@/roundtable-planning/lib/seat-mode";
/** Fields accepted when creating a chain. */
export interface ChainCreatePayload {
@@ -28,6 +29,8 @@ export interface ChainCreatePayload {
stageGoals?: (string | null)[] | null;
/** Optional coordinator orchestration prompt (dag-only). null/"" = none. */
coordinatorPrompt?: string | null;
/** 默认席位执行模式(flash/thinking/pro/ultra);null = 未设。 */
seatMode?: SeatMode | null;
}
/** Partial update — send only what changed. */
@@ -41,6 +44,8 @@ export interface ChainUpdatePayload {
stageGoals?: (string | null)[] | null;
/** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */
coordinatorPrompt?: string | null;
/** 默认席位执行模式;send `null` to clear, omit to leave. */
seatMode?: SeatMode | null;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean;
}
@@ -70,6 +75,7 @@ interface ServerChain {
stages?: string[][] | null;
stage_goals?: (string | null)[] | null;
coordinator_prompt?: string | null;
seat_mode?: string | null;
is_public?: boolean;
is_mine?: boolean;
owner_name?: string | null;
@@ -86,6 +92,7 @@ function toChain(s: ServerChain): BusinessChain {
stages: Array.isArray(s.stages) ? s.stages : null,
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
coordinatorPrompt: s.coordinator_prompt ?? null,
seatMode: (s.seat_mode as SeatMode | null) ?? undefined,
isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
@@ -104,6 +111,7 @@ function fromCreate(p: ChainCreatePayload) {
...(p.stages !== undefined ? { stages: p.stages } : {}),
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}),
...(p.seatMode !== undefined ? { seat_mode: p.seatMode } : {}),
};
}
@@ -117,6 +125,7 @@ function fromUpdate(p: ChainUpdatePayload) {
// Forward stage goals even when explicitly null (clears goals server-side).
if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals;
if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt;
if (p.seatMode !== undefined) out.seat_mode = p.seatMode;
if (p.isPublic !== undefined) out.is_public = p.isPublic;
return out;
}
@@ -202,6 +202,15 @@ export interface StreamMultiAgentRequest {
displayName?: string;
newMessage: string;
model?: string;
/**
* 仅 special 用。席位执行模式(快速/思考/专业问答/多智能体问答)派生的运行参数,
* 覆盖后端 `roundtable_run_policy` 的 thinking/reasoning 默认值。省略时后端按 policy
* 默认执行(向后兼容,旧前端不传不破坏)。三者通常一起传(见 `lib/seat-mode.ts`)。
*/
thinkingEnabled?: boolean;
reasoningEffort?: "minimal" | "low" | "medium" | "high";
/** 仅 special 用;ultra 档置真,给席位解锁 `task` 子代理工具。 */
subagentEnabled?: boolean;
skillStopNames?: string[];
/**
* 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付
@@ -444,6 +453,10 @@ export async function streamMultiAgent(
display_name: req.displayName,
new_message: req.newMessage,
model: req.model,
// 席位模式派生参数(仅 special);undefined 时不带上 → 后端按 policy 默认执行。
thinking_enabled: req.thinkingEnabled,
reasoning_effort: req.reasoningEffort,
subagent_enabled: req.subagentEnabled,
skill_stop_names: req.skillStopNames ?? [],
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
synthesis_mode: req.synthesisMode ?? false,
@@ -15,6 +15,7 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import { seatModeToRunParams } from "../lib/seat-mode";
import {
isTerminalJobStatus,
type DispatchChainNode,
@@ -75,6 +76,9 @@ function mapJob(raw: Record<string, unknown>): RoundtableJob {
}
function toStartBody(p: StartJobPayload) {
// 席位模式 → thinking/reasoning/subagent,与前端实时路径(/run/stream)同一份映射;
// 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
return {
draftId: p.draftId ?? null,
intent: p.intent,
@@ -86,6 +90,13 @@ function toStartBody(p: StartJobPayload) {
seedMessage: p.seedMessage,
threadIds: p.threadIds ?? null,
coordinatorName: p.coordinatorName ?? "roundtable-coordinator",
...(seat
? {
thinking_enabled: seat.thinking_enabled,
reasoning_effort: seat.reasoning_effort,
subagent_enabled: seat.subagent_enabled,
}
: {}),
};
}
@@ -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" />
@@ -13,7 +13,7 @@
*
* 详见 `docs/multi-agent-business-chain-dev.md` §6.2 / §7 阶段 3。
*/
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
ArrowRightIcon,
CheckIcon,
@@ -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";
@@ -44,12 +45,15 @@ export interface BusinessChainPickerProps {
onCancel: () => void;
/** 跳转到业务链条配置页(同时关闭弹窗)。 */
onGoConfig: () => void;
/** 选中链条变化时回调(含其 seatMode),父弹窗据此把执行模式默认设为链条的配置。 */
onSelectedChainChange?: (chain: BusinessChain | null) => void;
}
export function BusinessChainPicker({
onConfirm,
onCancel,
onGoConfig,
onSelectedChainChange,
}: BusinessChainPickerProps) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
@@ -102,6 +106,15 @@ export function BusinessChainPicker({
() => chains.find((c) => c.id === selectedId) ?? null,
[chains, selectedId],
);
// 选中链条变化 → 通知父弹窗(用 ref 持有回调,只在 selectedChain 真正变化时触发,
// 避免父级每次 render 重置用户已手动改过的执行模式)。
const onSelectedChainChangeRef = useRef(onSelectedChainChange);
useEffect(() => {
onSelectedChainChangeRef.current = onSelectedChainChange;
}, [onSelectedChainChange]);
useEffect(() => {
onSelectedChainChangeRef.current?.(selectedChain);
}, [selectedChain]);
const survivingSeats = useMemo(
() => (selectedChain ? selectedChain.seats.filter((s) => isExisting(s.agent_id)) : []),
[selectedChain, isExisting],
@@ -149,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}>
@@ -52,6 +52,8 @@ export interface MessageStepsCardProps {
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
function isToolStep(step: BubbleStep): boolean {
// `task`(派生子智能体)单独渲染成任务卡片(SubtaskCard),不在步骤折叠卡里重复展示。
if (step.key === "tool_calling:task") return false;
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
}
@@ -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 {
@@ -34,6 +35,11 @@ import { MessageStepsCard } from "./MessageStepsCard";
import { BusinessChainPicker } from "./BusinessChainPicker";
import type { IntentSummary } from "../api/intent";
import type { SeatSelectionResult } from "../lib/business-chain";
import {
DEFAULT_SEAT_MODE,
SEAT_MODE_OPTIONS,
type SeatMode,
} from "../lib/seat-mode";
import {
filterRecommendCandidates,
listRecommendHistory,
@@ -200,6 +206,9 @@ export function RecommendAgentsDialog({
const navigate = useNavigate();
// 席位来源模式:智能推荐 / 业务链条。默认**业务链条**。
const [mode, setMode] = useState<"recommend" | "chain">("chain");
// 席位执行模式(快速/思考/专业问答/多智能体问答)——推荐 / 业务链条两种来源通用,
// 决定第二步每个子智能体席位的推理深度。默认 pro(= 改造前席位固定行为)。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
const [state, setState] = useState<InternalState>(INITIAL_STATE);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -515,6 +524,14 @@ export function RecommendAgentsDialog({
return `已选 ${selectedCount} / ${MAX_SELECTED}`;
})();
// 统一在回传前合并席位模式,推荐 / 业务链条两条确认路径共用,保证 seatMode 总被带上。
const handleConfirmWithMode = useCallback(
(result: SeatSelectionResult) => {
onConfirm({ ...result, seatMode });
},
[onConfirm, seatMode],
);
const handleConfirm = useCallback(() => {
if (!canConfirm) return;
// Order: recommended-and-selected first (in recommender order), then
@@ -530,8 +547,8 @@ export function RecommendAgentsDialog({
for (const c of otherCards) {
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
}
onConfirm({ agents: ordered, mode: "recommend" });
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
handleConfirmWithMode({ agents: ordered, mode: "recommend" });
}, [canConfirm, recommendedCards, otherCards, selectedIds, handleConfirmWithMode]);
const handleGoConfig = useCallback(() => {
onCancel();
@@ -595,6 +612,35 @@ export function RecommendAgentsDialog({
</button>
</div>
{/* 席位执行模式:快速 / 思考 / 专业问答 / 多智能体问答。推荐 & 业务链条通用,
决定第二步每个子智能体席位的推理深度(thinking/reasoning/subagent)。 */}
<div className="shrink-0 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">执行模式</span>
<div className="inline-flex rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
{SEAT_MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSeatMode(opt.value)}
title={opt.description}
className={cn(
"rounded-md px-2.5 py-1 transition-colors",
seatMode === opt.value
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{SEAT_MODE_OPTIONS.find((o) => o.value === seatMode)?.description}
</p>
</div>
{/* ===== 智能推荐模式(现状,零行为变更)===== */}
{mode === "recommend" && (
<>
@@ -750,9 +796,13 @@ export function RecommendAgentsDialog({
{/* ===== 业务链条模式 ===== */}
{mode === "chain" && (
<BusinessChainPicker
onConfirm={onConfirm}
onConfirm={handleConfirmWithMode}
onCancel={onCancel}
onGoConfig={handleGoConfig}
onSelectedChainChange={(chain) => {
// 选中链条若配了默认执行模式 → 把顶部模式选择器默认设为它(用户仍可临时改)。
if (chain?.seatMode) setSeatMode(chain.seatMode);
}}
/>
)}
</DialogContent>
@@ -1073,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">
推荐
@@ -1082,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>
@@ -0,0 +1,125 @@
/**
* 圆桌「后台挂起作业」全工作区跨页完成通知器。
*
* 挂在 `PageLayout`(`/page/*` 的常驻层,导航切页不卸载)里:圆桌页点「后台挂起」后
* 调 `watchJob(jobId)` 注册监听,本 Provider 用 `streamJob` 订阅该作业 SSE;作业跑到
* 终态时弹一个**与「达成共识」同款**的顶部浮窗(bg-primary + Sparkles + bounce),
* 因此用户切到聊天/任务/其它工作区页面也能收到完成提示。
*
* 自带浮窗(不走 sonner)—— 各工作区页各自挂了一个 sonner `<Toaster>`,若这里也用
* sonner 会出现多 Toaster 重复弹;自渲染浮窗由本常驻 Provider 独占,天然不重复。
*
* 不含「刷新恢复」:watchedJobIds 是内存态,整页刷新后丢失(本期范围)。Provider 缺失
* 时(如 embed 路由在 PageRoutes 之外)`watchJob` 为 no-op,调用方不报错。
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Sparkles } from "lucide-react";
import { streamJob } from "@/roundtable-planning/api/roundtable-jobs";
import type { RoundtableJob } from "@/roundtable-planning/lib/job-types";
interface RoundtableJobNotifierValue {
/** 注册一个后台作业 id,作业完成(done/error)时弹全局提示。重复 id 自动去重。 */
watchJob: (jobId: string) => void;
}
const RoundtableJobNotifierContext = createContext<RoundtableJobNotifierValue>({
watchJob: () => {
/* 无 Provider(如 embed 路由)时 no-op,不报错 */
},
});
export function useRoundtableJobNotifier(): RoundtableJobNotifierValue {
return useContext(RoundtableJobNotifierContext);
}
/** 单个作业的 SSE 监听器(无 UI):到终态回调 onTerminal;传输中断回调 onStreamError。 */
function JobWatcher({
jobId,
onTerminal,
onStreamError,
}: {
jobId: string;
onTerminal: (job: RoundtableJob) => void;
onStreamError: (jobId: string) => void;
}) {
const onTerminalRef = useRef(onTerminal);
const onStreamErrorRef = useRef(onStreamError);
useEffect(() => {
onTerminalRef.current = onTerminal;
onStreamErrorRef.current = onStreamError;
}, [onTerminal, onStreamError]);
useEffect(() => {
const stop = streamJob(jobId, {
onProgress: () => {},
onDone: (job) => onTerminalRef.current(job),
onError: () => onStreamErrorRef.current(jobId),
});
return () => stop();
}, [jobId]);
return null;
}
/** 提示浮窗存活时长(ms)—— 比页内 toast(3.5s) 略长,给跨页用户更多时间看到。 */
const NOTICE_DURATION_MS = 6000;
export function RoundtableJobNotifier({ children }: { children: ReactNode }) {
const [watchedJobIds, setWatchedJobIds] = useState<string[]>([]);
const [notice, setNotice] = useState<string | null>(null);
const watchJob = useCallback((jobId: string) => {
setWatchedJobIds((prev) => (prev.includes(jobId) ? prev : [...prev, jobId]));
}, []);
const handleTerminal = useCallback((job: RoundtableJob) => {
setWatchedJobIds((prev) => prev.filter((id) => id !== job.id));
if (job.status === "done") {
setNotice("🎉 后台研讨已完成!可在「历史记录」查看结论与报告");
} else if (job.status === "error") {
setNotice("❌ 后台任务执行出错,请在「历史记录」中查看");
}
// cancelled:用户主动取消,不提示。
}, []);
const handleStreamError = useCallback((jobId: string) => {
// SSE 传输中断(非作业终态):静默停止监听,不误报完成。
setWatchedJobIds((prev) => prev.filter((id) => id !== jobId));
}, []);
useEffect(() => {
if (!notice) return;
const timer = setTimeout(() => setNotice(null), NOTICE_DURATION_MS);
return () => clearTimeout(timer);
}, [notice]);
return (
<RoundtableJobNotifierContext.Provider value={{ watchJob }}>
{children}
{watchedJobIds.map((id) => (
<JobWatcher
key={id}
jobId={id}
onTerminal={handleTerminal}
onStreamError={handleStreamError}
/>
))}
{notice && (
<div
className="fixed top-4 left-1/2 z-[100] flex -translate-x-1/2 transform items-center gap-2 rounded-xl border border-primary/40 bg-primary px-4 py-2.5 text-xs font-medium text-white shadow-lg animate-bounce"
role="status"
>
<Sparkles className="h-4 w-4 shrink-0" />
<span>{notice}</span>
</div>
)}
</RoundtableJobNotifierContext.Provider>
);
}
@@ -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">
@@ -57,6 +57,8 @@ import {
StreamingCursor,
} from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { SubtaskCard } from "@/components/workspace/messages/subtask-card";
import { useSubtask } from "@/core/tasks/context";
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
import { DagFlowMiniPanel } from "@/roundtable-planning/components/DagFlowMiniPanel";
import { useArtifacts } from "@/components/workspace/artifacts";
@@ -79,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";
@@ -403,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>
@@ -582,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>
@@ -597,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>
@@ -613,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) => (
@@ -876,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",
@@ -909,6 +916,15 @@ export function Step2Message({
resolveAgentName={resolveAgentName}
/>
)}
{/* 「多智能体问答」模式:本席位派生的子智能体任务卡片(复用主聊天 SubtaskCard,
从共享 SubtaskContext 取状态)。同时发起多个 task → 多张卡片纵向堆叠。 */}
{Array.isArray(msg.subtaskIds) && msg.subtaskIds.length > 0 && (
<div className="flex w-full flex-col gap-2">
{msg.subtaskIds.map((taskId) => (
<RoundtableSubtaskCard key={taskId} taskId={taskId} isLoading={isStreaming} />
))}
</div>
)}
{reasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
)}
@@ -932,6 +948,23 @@ export function Step2Message({
);
}
/**
* 子智能体任务卡片包裹层:子任务内容是**实时态**,存放在共享 SubtaskContext(主聊天同一份);
* 草稿回显 / 历史记录等场景气泡上虽留有 subtaskId,但 context 里已无对应 Subtask —— 此时
* 返回 null 不渲染(而非让 SubtaskCard 的 `useSubtask(id)!` 取到 undefined 崩溃)。
*/
function RoundtableSubtaskCard({
taskId,
isLoading,
}: {
taskId: string;
isLoading: boolean;
}) {
const task = useSubtask(taskId);
if (!task) return null;
return <SubtaskCard taskId={taskId} isLoading={isLoading} />;
}
/**
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
*
@@ -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>
)}
@@ -120,6 +120,11 @@ export interface UseDraftPersistenceReturn {
* 发起前 / 点查看前各存一次,保住当时的聊天记录不丢。返回 draft id。
*/
saveDraftNow: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/**
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。「后台挂起直接跳转」用:
* 跳转会清空 step2,调用方在跳转前冻结快照传进来,保证落库的是挂起那一刻的研讨记录。
*/
saveStep2SnapshotNow: (step2: DraftStep2Snapshot, furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/** 强制刷新草稿列表。 */
refresh: () => Promise<void>;
}
@@ -287,6 +292,51 @@ export function useDraftPersistence(
[currentStep, enqueueSave, refreshList],
);
/**
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。
*
* 「后台挂起改直接跳转」用:点挂起后页面立刻 resetToStep1 会清空 step2,而存稿是
* fire-and-forget 在跳转之后才真正落库——若仍走 `saveDraftNow`(其 `getStep2Snapshot`
* 是延迟读取)就会存到被重置后的空 step2。调用方在跳转前同步 `getStep2Snapshot()` 冻结
* 一份快照传进来,这里只认这份,确保存的是挂起那一刻的研讨记录。
*
* 有草稿 id → update(保护重命名,不带 title);无 id → 用当前 step1 + 传入 step2 建档。
*/
const saveStep2SnapshotNow = useCallback(
(step2: DraftStep2Snapshot, furthestStep: 1 | 2 | 3 = 2): Promise<string | null> => {
const run = async (): Promise<string | null> => {
const id = currentDraftIdRef.current;
if (id) {
const updated = await updateDraft(id, { furthestStep, step2 });
if (updated) {
currentDraftIdRef.current = updated.id;
return updated.id;
}
// 服务端已不存在 → 落回新建。
}
const step1 = getStep1Snapshot();
if (!hasSavableContent(step1, step2)) return null;
const created = await createDraft({
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
title: deriveDraftName({
intentReady: step1.intentReady,
interactiveMessages: step1.interactiveMessages,
}),
furthestStep,
step1,
step2,
});
currentDraftIdRef.current = created.id;
setCurrentDraftId(created.id);
return created.id;
};
const next = saveQueueRef.current.then(run, run);
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[getStep1Snapshot, hasSavableContent, setCurrentDraftId],
);
const handleManualSave = useCallback(() => {
const furthest = (currentStep as 1 | 2 | 3) || 1;
void (async () => {
@@ -535,6 +585,7 @@ export function useDraftPersistence(
handleCancelRename,
ensureDraft,
saveDraftNow,
saveStep2SnapshotNow,
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
refresh: refreshList,
};
@@ -27,6 +27,7 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { uploadFiles } from "@/core/uploads/api";
import { useUpdateSubtask } from "@/core/tasks/context";
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
@@ -37,12 +38,56 @@ function takeOnce<T>(ref: { current: T | null }): T | undefined {
ref.current = null;
return v;
}
/**
* 从一帧里识别子智能体 custom 事件(task_started/running/completed/failed/cancelled/
* timed_out)。custom 流帧通常是裸 payload 对象 `{type:"task_*", task_id, ...}`;
* 开启 stream_subgraphs 时也可能被包成 `[namespace, payload]`,这里两种都兼容。
*/
function asSubagentTaskEvent(frame: unknown): {
type: string;
task_id: string;
message?: unknown;
result?: unknown;
error?: unknown;
description?: unknown;
} | null {
const cand =
frame && typeof frame === "object" && !Array.isArray(frame)
? (frame as Record<string, unknown>)
: Array.isArray(frame) && frame.length >= 2 && frame[1] && typeof frame[1] === "object"
? (frame[1] as Record<string, unknown>)
: null;
if (!cand) return null;
const type = typeof cand.type === "string" ? cand.type : "";
const taskId = typeof cand.task_id === "string" ? cand.task_id : "";
if (!type.startsWith("task_") || !taskId) return null;
return { type, task_id: taskId, message: cand.message, result: cand.result, error: cand.error, description: cand.description };
}
/** 从 LangGraph 消息 chunk 的 content(string 或结构化 block 数组)里抽纯文本。 */
function extractFrameText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
let out = "";
for (const item of content) {
if (typeof item === "string") out += item;
else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
if (typeof block.text === "string") out += block.text;
}
}
return out;
}
return "";
}
import {
buildDagStageLeaderTask,
pickDagSeatTask,
type OrchestrationPlan,
} from "@/roundtable-planning/lib/dag-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import { seatModeToRequestFields, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
import {
DEFAULT_SELECTED_AGENTS,
MAX_CYCLES,
@@ -65,6 +110,12 @@ export type Step2Dialogue = {
streaming?: boolean;
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
agentThreadId?: string;
/**
* 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具
* 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片
* (复用主聊天 `SubtaskCard`,见 Step2Panel.Step2Message)。非 ultra 模式恒为空。
*/
subtaskIds?: string[];
steps?: Array<{
id: string;
key: string;
@@ -222,6 +273,12 @@ export interface UseStep2OrchestrationOptions {
currentStep: number;
/** 全局选中模型(顶栏「推理模型」下拉)。 */
selectedModel: string;
/**
* 席位执行模式(快速/思考/专业问答/多智能体问答)。决定第二步**每个子智能体席位**
* 的推理深度(thinking/reasoning_effort/subagent);总控派活不受影响。由「选择参会
* 智能体」弹窗确认时的 `SeatSelectionResult.seatMode` 决定。缺省 `pro`。
*/
selectedMode: SeatMode;
/**
* 编排模式:
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
@@ -376,6 +433,7 @@ export function useStep2Orchestration(
const {
currentStep,
selectedModel,
selectedMode,
orchestrationMode,
orchestrationPlan,
dagExecMode = "fast",
@@ -507,6 +565,8 @@ export function useStep2Orchestration(
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
// 改写都重建一遍循环函数 —— 之前没收窄时 useEffect 会因 deps 变化重复 init。
const selectedModelRef = useRef<string>(selectedModel);
// 席位执行模式同样打到 ref,让席位调用在编排循环里读到最新值而不重建回调。
const selectedModeRef = useRef<SeatMode>(selectedMode);
const agentConfigsRef = useRef<Record<number, AgentConfig>>(agentConfigs);
const agentNameToRoleRef = useRef(agentNameToRole);
const threadIdsRef = useRef<ThreadIdMap | null>(threadIds);
@@ -543,6 +603,9 @@ export function useStep2Orchestration(
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
useEffect(() => {
selectedModeRef.current = selectedMode;
}, [selectedMode]);
useEffect(() => {
agentConfigsRef.current = agentConfigs;
}, [agentConfigs]);
@@ -945,6 +1008,137 @@ export function useStep2Orchestration(
[upsertSeatToolCall],
);
// ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)──────────
// ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里:
// (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress);
// (2) type==="tool" 且 tool_call_id 命中已知 task id → 据「Task Succeeded./failed./
// timed out」前缀更新为 completed/failed(与主聊天 message-list.tsx 同源解析)。
// Subtask 存进共享 SubtaskContext(主聊天同一份),气泡只记 id 列表并渲染 <SubtaskCard>。
const updateSubtask = useUpdateSubtask();
// 已识别的 task tool_call_id —— 用于在收到 tool 结果帧时只认领「子智能体任务」结果,
// 不误把 write_file 等其它工具的结果当成子任务处理。
const knownTaskIdsRef = useRef<Set<string>>(new Set());
/** 把某个子任务 id 记到对应席位气泡上(新引用 → 触发气泡重渲染,卡片随之刷新状态)。 */
const touchBubbleSubtaskId = useCallback((bubbleId: number, taskId: string) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => {
if (d.id !== bubbleId) return d;
const ids = d.subtaskIds ?? [];
// 每次都换新数组引用,确保状态(in_progress→completed/failed)变化时卡片重渲染。
return { ...d, subtaskIds: ids.includes(taskId) ? [...ids] : [...ids, taskId] };
}),
);
}, []);
/** 处理一条消息(values 快照里的整条 message,或 messages-tuple 的 chunk)的子任务语义。 */
const ingestSubagentMessage = useCallback(
(msg: Record<string, unknown>, bubbleId: number) => {
// (1) AI 消息:抓 task 工具调用 → 建/更新 in_progress 子任务。
const toolCalls = msg.tool_calls;
if (Array.isArray(toolCalls)) {
for (const tc of toolCalls) {
if (!tc || typeof tc !== "object") continue;
const t = tc as Record<string, unknown>;
if (t.name !== "task") continue;
const id = typeof t.id === "string" ? t.id : "";
if (!id) continue;
const a = (t.args && typeof t.args === "object" ? t.args : {}) as Record<string, unknown>;
updateSubtask({
id,
status: "in_progress",
subagent_type: typeof a.subagent_type === "string" ? a.subagent_type : "",
description: typeof a.description === "string" ? a.description : "",
prompt: typeof a.prompt === "string" ? a.prompt : "",
});
knownTaskIdsRef.current.add(id);
touchBubbleSubtaskId(bubbleId, id);
}
}
// (2) tool 结果消息:命中已知 task id 时按前缀判完成/失败(与主聊天解析一致)。
const type = typeof msg.type === "string" ? msg.type.toLowerCase() : "";
if (type === "tool") {
const taskId = typeof msg.tool_call_id === "string" ? msg.tool_call_id : "";
if (taskId && knownTaskIdsRef.current.has(taskId)) {
const result = extractFrameText(msg.content);
if (result.startsWith("Task Succeeded. Result:")) {
updateSubtask({
id: taskId,
status: "completed",
result: result.split("Task Succeeded. Result:")[1]?.trim(),
});
} else if (result.startsWith("Task failed.")) {
updateSubtask({
id: taskId,
status: "failed",
error: result.split("Task failed.")[1]?.trim(),
});
} else if (result.startsWith("Task timed out")) {
updateSubtask({ id: taskId, status: "failed", error: result });
}
touchBubbleSubtaskId(bubbleId, taskId);
}
}
},
[updateSubtask, touchBubbleSubtaskId],
);
const ingestSubagentTaskFrame = useCallback(
(frame: unknown, bubbleId: number) => {
// custom 事件帧(ultra 席位子智能体进度):task_running 带子智能体最新消息 → 回填
// latestMessage(卡片实时显示「正在调用 X 工具」);完成/失败即时定状态(比 values 更快)。
const ev = asSubagentTaskEvent(frame);
if (ev) {
knownTaskIdsRef.current.add(ev.task_id);
if (ev.type === "task_running") {
// latestMessage 非空才会触发 SubtaskContext setTasks 重渲染(见 useUpdateSubtask)。
updateSubtask({ id: ev.task_id, status: "in_progress", latestMessage: ev.message as never });
} else if (ev.type === "task_started") {
updateSubtask({
id: ev.task_id,
status: "in_progress",
description: typeof ev.description === "string" ? ev.description : undefined,
});
} else if (ev.type === "task_completed") {
updateSubtask({
id: ev.task_id,
status: "completed",
result: typeof ev.result === "string" ? ev.result : extractFrameText(ev.result),
});
} else if (
ev.type === "task_failed" ||
ev.type === "task_cancelled" ||
ev.type === "task_timed_out"
) {
updateSubtask({
id: ev.task_id,
status: "failed",
error: typeof ev.error === "string" ? ev.error : "子智能体任务失败",
});
}
touchBubbleSubtaskId(bubbleId, ev.task_id);
return;
}
// values 快照帧:{messages:[...]} —— 权威来源,按整条消息列表对账(子任务完成/失败)。
if (frame && typeof frame === "object" && !Array.isArray(frame)) {
const msgs = (frame as Record<string, unknown>).messages;
if (Array.isArray(msgs)) {
for (const m of msgs) {
if (m && typeof m === "object") ingestSubagentMessage(m as Record<string, unknown>, bubbleId);
}
}
return;
}
// messages-tuple 帧:[chunk, metadata] —— 增量,派活时即时建卡。
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0];
if (!chunk || typeof chunk !== "object") return;
ingestSubagentMessage(chunk as Record<string, unknown>, bubbleId);
},
[ingestSubagentMessage, updateSubtask, touchBubbleSubtaskId],
);
// ─── Dialogue / Speaker helpers ────────────────────────────────────
const appendDialogue = useCallback(
(card: {
@@ -1406,6 +1600,8 @@ export function useStep2Orchestration(
newMessage: fullTask,
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式(快速/思考/专业问答/多智能体问答)→ thinking/reasoning/subagent。
...seatModeToRequestFields(selectedModeRef.current),
signal: subAborter.signal,
onTextDelta: (text) =>
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
@@ -1415,7 +1611,10 @@ export function useStep2Orchestration(
appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, a.seatThreadId),
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, a.seatThreadId);
ingestSubagentTaskFrame(frame, a.bubbleId);
},
});
activeAbortersRef.current.delete(subAborter);
if (isStale()) {
@@ -1690,6 +1889,7 @@ export function useStep2Orchestration(
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId,
],
);
@@ -1829,6 +2029,8 @@ export function useStep2Orchestration(
displayName: seatName,
newMessage: task,
model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式 → thinking/reasoning/subagent(与推荐/DAG 路径一致)。
...seatModeToRequestFields(selectedModeRef.current),
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
@@ -1837,7 +2039,10 @@ export function useStep2Orchestration(
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, subBubbleId);
},
});
if (isStale()) {
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
@@ -1936,6 +2141,7 @@ export function useStep2Orchestration(
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId,
],
);
@@ -2163,6 +2369,8 @@ export function useStep2Orchestration(
// 前序各阶段的完整交付 → 供本席位 read_peer_delivery 按需取全文(§12.9)。
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式 → thinking/reasoning/subagent(与推荐/链条路径一致)。
...seatModeToRequestFields(selectedModeRef.current),
signal: subAborter.signal,
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
@@ -2174,7 +2382,10 @@ export function useStep2Orchestration(
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, bubbleId);
},
});
if (isStale()) {
finalizeDialogue(bubbleId, specRes.content || "(已停止)", "已停止");
@@ -2276,6 +2487,7 @@ export function useStep2Orchestration(
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId,
ingestParallelDelta,
resetParallelStreams,
@@ -10,6 +10,7 @@
*/
import { buildPlanFromStages } from "./dag-types";
import type { SeatMode } from "./seat-mode";
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
export interface ChainSeat {
@@ -48,6 +49,11 @@ export interface BusinessChain {
* 空 / null = 不注入。
*/
coordinatorPrompt?: string | null;
/**
* 默认席位执行模式(快速/思考/专业问答/多智能体)。选入研讨时弹窗默认用它(仍可临时改)。
* 缺省 = 未设,调用方回退 `DEFAULT_SEAT_MODE`(pro)。
*/
seatMode?: SeatMode;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
@@ -85,6 +91,12 @@ export interface SeatSelectionResult {
* 缺省 `fast`。
*/
dagExecMode?: "fast" | "sandbox";
/**
* 席位执行模式(快速/思考/专业问答/多智能体问答)——决定第二步**每个子智能体席位**
* 的推理深度(thinking/reasoning_effort/subagent)。三种编排(recommend/chain/dag)通用。
* 缺省由调用方按 `DEFAULT_SEAT_MODE`(pro)处理。
*/
seatMode?: SeatMode;
}
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
@@ -132,12 +144,12 @@ export function buildChainSeatSelection(
const agents = plan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id) }));
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast" };
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode };
}
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
return { agents, mode: "chain", chain: chainMeta };
return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode };
}
/**
@@ -9,6 +9,7 @@
*/
import type { OrchestrationPlan } from "./dag-types";
import type { SeatMode } from "./seat-mode";
/** 作业状态机(§2.1)。 */
export type JobStatus =
@@ -120,6 +121,12 @@ export interface StartJobPayload {
/** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */
threadIds?: Record<string, string> | null;
coordinatorName?: string;
/**
* 席位执行模式(快速/思考/专业问答/多智能体)。后台续跑时席位按此模式执行(与前端实时
* 路径一致);缺省由后端按 policy 默认(≈pro)。在 `toStartBody` 里派生成 thinking/
* reasoning/subagent 三字段发给后端。
*/
seatMode?: SeatMode;
}
/** 终态集合——SSE/轮询判定作业是否结束。 */
@@ -0,0 +1,77 @@
/**
* 圆桌第二步「席位执行模式」共享定义。
*
* 与主聊天输入框(`components/workspace/input-box.tsx`)的四档模式同源:
* 用户在「选择参会智能体」弹窗里选一档,第二步派活的**每个子智能体(special 席位)**
* 都按这一档的推理深度执行。模式只决定 `thinking_enabled / reasoning_effort /
* subagent_enabled` 三个运行参数;席位的工具裁剪仍由后端 `roundtable_run_policy`
* 决定(见 `app/gateway/roundtable_run_policy.py`)。
*
* 总控(leader)派活是短决策任务,**不**受本模式影响(沿用其快速派活策略)。
*
* 映射逻辑刻意对齐主聊天 `core/threads/hooks.ts` 的 mode → context 转换,保持一致体验:
* - flash 快速 —— 不思考、最快
* - thinking 思考 —— 思考 + low
* - pro 专业问答 —— 思考 + medium(= 现有席位默认行为,向后兼容)
* - ultra 多智能体 —— 思考 + high + 席位可派子代理(task 工具)
*/
export type SeatMode = "flash" | "thinking" | "pro" | "ultra";
/** 默认档位:专业问答(pro)—— 与改造前席位的固定行为(thinking + medium)一致。 */
export const DEFAULT_SEAT_MODE: SeatMode = "pro";
/** 席位单轮 run 的派生参数(透传给 `/api/multi-agent/run/stream`,覆盖后端 policy 默认)。 */
export interface SeatRunParams {
thinking_enabled: boolean;
reasoning_effort: "minimal" | "low" | "medium" | "high";
subagent_enabled: boolean;
}
/** 把一个席位模式映射成具体的运行参数。 */
export function seatModeToRunParams(mode: SeatMode): SeatRunParams {
switch (mode) {
case "flash":
return { thinking_enabled: false, reasoning_effort: "minimal", subagent_enabled: false };
case "thinking":
return { thinking_enabled: true, reasoning_effort: "low", subagent_enabled: false };
case "ultra":
return { thinking_enabled: true, reasoning_effort: "high", subagent_enabled: true };
case "pro":
default:
return { thinking_enabled: true, reasoning_effort: "medium", subagent_enabled: false };
}
}
/**
* 把席位模式映射成 `streamMultiAgent` 请求字段(camelCase),方便在席位调用处直接 spread。
* 与 `seatModeToRunParams` 同一份逻辑,只是改成 API 客户端期望的字段名。
*/
export function seatModeToRequestFields(mode: SeatMode): {
thinkingEnabled: boolean;
reasoningEffort: SeatRunParams["reasoning_effort"];
subagentEnabled: boolean;
} {
const p = seatModeToRunParams(mode);
return {
thinkingEnabled: p.thinking_enabled,
reasoningEffort: p.reasoning_effort,
subagentEnabled: p.subagent_enabled,
};
}
/** 弹窗模式选择器用的展示元数据(标签 + 一句话说明),顺序即展示顺序。 */
export const SEAT_MODE_OPTIONS: ReadonlyArray<{
value: SeatMode;
label: string;
description: string;
}> = [
{ value: "flash", label: "快速", description: "快速且高效地完成任务,但可能不够精准" },
{ value: "thinking", label: "思考", description: "思考后再行动,在时间与准确性之间取得平衡" },
{ value: "pro", label: "专业问答", description: "思考、计划再执行,获得更精准的结果,可能需要更多时间" },
{
value: "ultra",
label: "多智能体问答",
description: "在专业问答基础上,每个席位可调用子代理分工协作,能力最强、更慢更耗时",
},
];
@@ -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";
@@ -85,6 +86,11 @@ import {
type BusinessChain,
type ChainSeat,
} from "@/roundtable-planning/lib/business-chain";
import {
DEFAULT_SEAT_MODE,
SEAT_MODE_OPTIONS,
type SeatMode,
} from "@/roundtable-planning/lib/seat-mode";
import {
addEmptyStage,
addSeatAsNewStage,
@@ -129,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[]>([]);
@@ -153,6 +161,8 @@ export default function BusinessChainEditorPage() {
const [description, setDescription] = useState("");
// 写给总控的「编排提示」:告诉总控该怎么派活、做什么(仅分层 DAG 模式注入)。
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
// 默认席位执行模式(快速/思考/专业问答/多智能体)—— 选入研讨时弹窗默认用它。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
const [saving, setSaving] = useState(false);
@@ -277,6 +287,7 @@ export default function BusinessChainEditorPage() {
setTitle(chain.title);
setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setSeatMode(chain.seatMode ?? DEFAULT_SEAT_MODE);
setStageGroups(chainToStageGroups(chain));
setStep(1);
}, []);
@@ -288,6 +299,7 @@ export default function BusinessChainEditorPage() {
setTitle("");
setDescription("");
setCoordinatorPrompt("");
setSeatMode(DEFAULT_SEAT_MODE);
setStageGroups([]);
setStep(1);
}, []);
@@ -560,6 +572,7 @@ export default function BusinessChainEditorPage() {
stages,
stageGoals,
coordinatorPrompt: coordPrompt,
seatMode,
});
if (!updated) {
toast.error("链条已不存在,可能被删除");
@@ -578,6 +591,7 @@ export default function BusinessChainEditorPage() {
stages,
stageGoals,
coordinatorPrompt: coordPrompt,
seatMode,
});
setCurrentChainId(created.id);
toast.success("已创建业务链条");
@@ -592,6 +606,7 @@ export default function BusinessChainEditorPage() {
title,
description,
coordinatorPrompt,
seatMode,
stageGroups,
seatCountInvalid,
currentChainId,
@@ -699,6 +714,37 @@ export default function BusinessChainEditorPage() {
/>
</div>
{/* 默认执行模式:选入研讨时弹窗默认用它(仍可临时改)。 */}
<div className="space-y-2">
<label className="flex items-center gap-1 text-xs font-semibold text-foreground">
默认执行模式
<span className="text-[10px] font-normal text-muted-foreground">
(选入研讨时默认用它,进研讨前仍可临时切换)
</span>
</label>
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
{SEAT_MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSeatMode(opt.value)}
title={opt.description}
className={cn(
"rounded-md px-2.5 py-1 transition-colors",
seatMode === opt.value
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{SEAT_MODE_OPTIONS.find((o) => o.value === seatMode)?.description}
</p>
</div>
{/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
@@ -761,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,8 +101,10 @@ 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";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import {
@@ -108,7 +113,9 @@ import {
startJob,
startJobKeepalive,
} from "@/roundtable-planning/api/roundtable-jobs";
import { getChain } from "@/roundtable-planning/api/chains";
import { useRoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
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";
@@ -169,6 +176,8 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
export default function RoundtablePlanningPage() {
const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
const { watchJob } = useRoundtableJobNotifier();
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
@@ -222,6 +231,9 @@ export default function RoundtablePlanningPage() {
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的
// 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活;
// dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -337,6 +349,7 @@ export default function RoundtablePlanningPage() {
const step2 = useStep2Orchestration({
currentStep,
selectedModel,
selectedMode: seatMode,
orchestrationMode,
orchestrationPlan,
dagExecMode,
@@ -750,6 +763,8 @@ export default function RoundtablePlanningPage() {
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活;
// dag = 分层并行(来源链条带 stages,携带 result.plan)。
setOrchestrationMode(result.mode);
// 席位执行模式:弹窗未带(旧调用)时回退默认 pro,保证行为可预期。
setSeatMode(result.seatMode ?? DEFAULT_SEAT_MODE);
setChainMeta(result.mode === "recommend" ? null : result.chain ?? null);
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast");
@@ -766,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(() => {
@@ -1133,12 +1176,36 @@ export default function RoundtablePlanningPage() {
});
}, [resetToStep1]);
// 「挂起中」本地标记:点「后台挂起」后立刻跳转,建作业是 fire-and-forget——从点击到作业
// 真正建好的窗口里,用它让历史记录显示「挂起中」(作业建好后转 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);
next.add(draftId);
return next;
});
}, []);
const clearPendingSuspend = useCallback((draftId: string) => {
setPendingSuspendIds((prev) => {
if (!prev.has(draftId)) return prev;
const next = new Set(prev);
next.delete(draftId);
return next;
});
}, []);
// 「后台挂起」核心(手动按钮 / 离开页面自动挂起共用):幂等保证草稿存在 →
// 同草稿已有在跑作业则跳过 → 启动后台作业。后端另有「同草稿未终态作业幂等返回」
// 兜底(覆盖跨标签页 / beforeunload 与本函数并发触发),绝不重复建作业。
const startBackgroundJob = useCallback(async (): Promise<boolean> => {
// 返回本次后台作业的 jobId(已在跑则返回既有作业 id),供「完成时提示」监听;
// 未发起(无席位)返回 null。
const startBackgroundJob = useCallback(async (): Promise<string | null> => {
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
if (agents.length === 0) return false;
if (agents.length === 0) return null;
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
const draftId = await drafts.saveDraftNow(2);
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
@@ -1148,52 +1215,118 @@ export default function RoundtablePlanningPage() {
(existing.status === "running" ||
existing.status === "awaiting_input" ||
existing.status === "queued");
if (!alreadyRunning) {
await startJob({
draftId: draftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
orchestrationMode,
chain: chainMeta,
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。
});
if (alreadyRunning) {
return existing?.id ?? null;
}
return true;
const job = await startJob({
draftId: draftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
seatMode, // 席位执行模式沿用第二步选择(后台续跑同样按此模式跑席位)
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
orchestrationMode,
chain: chainMeta,
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。
});
return job.id;
}, [
step2,
drafts,
selectedModel,
seatMode,
orchestrationMode,
orchestrationPlan,
chainMeta,
buildInitialIntent,
]);
// 「后台挂起」按钮:把当前研讨交给后端自动跑完 Step2 研讨 + Step3(总结报告 + 结果绘制)。
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
const handleBackgroundSuspend = useCallback(async () => {
if (step2.selectedAgents.length === 0) {
// 「后台挂起」按钮(2026-06-16 改「直接跳转」):点击后**立刻**跳回第一步并提示,挂起动作
// (存稿 → 去重 → 建后台作业)改为 fire-and-forget 在后台完成,不再等接口返回才跳转。
// 历史记录在作业真正建好前显示「挂起中」(pendingSuspendIds),建好后由后端状态(queued→
// running)接管。⚠️ resetToStep1 会清空 step2 等 live 状态,故挂起入参 + step2 快照必须在
// 跳转前同步冻结(getStep2SnapshotWithRuns 返回的是不可变快照对象,reset 替换 state 不影响它)。
const handleBackgroundSuspend = useCallback(() => {
const selected = step2.selectedAgents;
if (selected.length === 0) {
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
return;
}
try {
await startBackgroundJob();
} catch (err) {
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
return;
}
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
// —— 跳转前同步冻结所有需要的输入(reset 之后这些 live 状态都会被清空)——
const step2Snapshot = getStep2SnapshotWithRuns();
const agents = selected.map((a) => ({ agentId: a.agent_id, name: a.name }));
const intent = buildInitialIntent();
const model = selectedModel || undefined;
const seatModeSnap = seatMode;
const mode = orchestrationMode;
const chainSnap = chainMeta;
const planSnap = mode === "dag" ? orchestrationPlan : null;
const draftId = drafts.currentDraftId;
// 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。
if (draftId) markPendingSuspend(draftId);
resetToStep1();
triggerToast("🚀 已转入后台,任务会自动跑完研讨、总结报告与结果绘制;可在「历史记录」查看进度");
void drafts.refresh();
}, [step2, drafts, startBackgroundJob, resetToStep1, triggerToast]);
// 后台 fire-and-forget:用冻结快照存稿 → 去重 → 建作业 → 注册完成通知。失败只提示、不挡跳转。
void (async () => {
let watchId: string | null = null;
try {
const savedId = (await drafts.saveStep2SnapshotNow(step2Snapshot, 2)) ?? draftId ?? null;
// 防重复:该草稿已有未终态作业就不再新建(同 startBackgroundJob 的客户端去重,
// 后端另有「同草稿未终态作业幂等返回」兜底)。
const existing = savedId ? await getJobByDraft(savedId).catch(() => null) : null;
const alreadyRunning =
existing &&
(existing.status === "running" ||
existing.status === "awaiting_input" ||
existing.status === "queued");
if (alreadyRunning) {
watchId = existing?.id ?? null;
} else {
const job = await startJob({
draftId: savedId ?? undefined,
intent,
agents,
model,
seatMode: seatModeSnap,
orchestrationMode: mode,
chain: chainSnap,
orchestrationPlan: planSnap,
});
watchId = job.id;
}
} catch (err) {
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
} finally {
// 注册到全工作区通知器,作业完成时**跨页**弹「达成共识同款」提示。
if (watchId) watchJob(watchId);
// 清「挂起中」本地标记:此后历史记录的状态由后端作业(queued→running)权威接管。
if (draftId) clearPendingSuspend(draftId);
void drafts.refresh();
}
})();
}, [
step2,
drafts,
getStep2SnapshotWithRuns,
buildInitialIntent,
selectedModel,
seatMode,
orchestrationMode,
orchestrationPlan,
chainMeta,
resetToStep1,
triggerToast,
watchJob,
markPendingSuspend,
clearPendingSuspend,
]);
// ── 自动后台挂起:研讨进行中离开本页面(路由卸载)或刷新 / 关标签页时,任务自动转后台 ──
// 最新状态进 ref:卸载 cleanup 与 beforeunload 都是「拍快照时刻」的同步回调,
@@ -1223,6 +1356,7 @@ export default function RoundtablePlanningPage() {
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined,
seatMode,
orchestrationMode,
chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
@@ -1347,7 +1481,7 @@ export default function RoundtablePlanningPage() {
)}
{/* HEADER */}
<header className="relative z-[2000] 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}
@@ -1408,6 +1542,7 @@ export default function RoundtablePlanningPage() {
setIsOpen={drafts.setIsHistoryOpen}
drafts={drafts.drafts}
currentDraftId={drafts.currentDraftId}
pendingSuspendIds={pendingSuspendIds}
renamingDraftId={drafts.renamingDraftId}
renameDraftValue={drafts.renameDraftValue}
setRenameDraftValue={drafts.setRenameDraftValue}
@@ -1457,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 */}
@@ -1733,6 +1871,8 @@ interface HistoryDropdownProps {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
drafts: ReturnType<typeof useDraftPersistence>["drafts"];
currentDraftId: string | null;
/** 正在「挂起中」(已点后台挂起、作业尚未建好)的草稿 id 集合 —— 历史记录显示「挂起中」。 */
pendingSuspendIds: Set<string>;
renamingDraftId: string | null;
renameDraftValue: string;
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
@@ -1755,6 +1895,7 @@ function HistoryDropdown(props: HistoryDropdownProps) {
setIsOpen,
drafts,
currentDraftId,
pendingSuspendIds,
renamingDraftId,
renameDraftValue,
setRenameDraftValue,
@@ -1825,6 +1966,8 @@ function HistoryDropdown(props: HistoryDropdownProps) {
{drafts.map((d) => {
const isCurrent = d.id === currentDraftId;
const isRenaming = d.id === renamingDraftId;
// 「挂起中」:已点后台挂起、作业还没建好(本地标记),或作业刚建好仍排队(queued)。
const isSuspending = pendingSuspendIds.has(d.id) || d.status === "queued";
// 「进行中 / 待澄清」且有 jobId → 点击打开流程图弹窗,而非加载草稿。
const isActiveJob =
(d.status === "running" || d.status === "awaiting_input") && !!d.jobId;
@@ -1862,7 +2005,12 @@ function HistoryDropdown(props: HistoryDropdownProps) {
{d.name}
</span>
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono">
{d.status === "running" ? (
{isSuspending ? (
<span className="inline-flex items-center gap-1 text-sky-500 font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
挂起中
</span>
) : d.status === "running" ? (
<span className="inline-flex items-center gap-1 text-primary font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
进行中
@@ -1946,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({
@@ -1987,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) =>
@@ -1999,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",
@@ -2113,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,
@@ -2759,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}
@@ -2806,7 +3086,7 @@ function RoleRow({
clickable
? active
? "取消筛选,显示全部发言"
: `只看 ${role.name} 的发言`
: `只看 ${desensitize(role.name)} 的发言`
: undefined
}
onClick={onSelect}
@@ -2836,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>
@@ -2906,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"
>
+13 -12
View File
@@ -9,7 +9,10 @@ import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import { useUnreadCount } from "@/core/notifications";
import { useMyPosition } from "@/core/positions";
import type { MenuItem } from "@/core/page-layout/sidebar-menu";
import {
getActiveThirdLevelItems,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { cn } from "@/lib/utils";
@@ -148,7 +151,7 @@ const Header = ({ collapsed }: HeaderProps) => {
const showLightTopNav = !isDark && !isWujieEnv();
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
@@ -173,9 +176,7 @@ const Header = ({ collapsed }: HeaderProps) => {
return null;
};
const activeTopItem = visibleMenuItems.find(hasActiveChild);
const activeSecondItem = activeTopItem?.children?.find(hasActiveChild);
const thirdSubNavItems = activeSecondItem?.children ?? [];
const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id));
@@ -188,7 +189,7 @@ const Header = ({ collapsed }: HeaderProps) => {
};
const renderTopItem = (item: MenuItem) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
return (
<button
key={item.id}
@@ -202,7 +203,7 @@ const Header = ({ collapsed }: HeaderProps) => {
};
const renderLightTopItem = (item: MenuItem) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
return (
<button
key={item.id}
@@ -302,7 +303,7 @@ const Header = ({ collapsed }: HeaderProps) => {
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<header className="strategy-app-header theme-transition relative z-[2101] flex h-16 items-center justify-between px-4">
<header className="strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4">
{showTopNav ? (
<>
{/* 左分区(flex-1):菜单靠右贴近中间基座 */}
@@ -318,7 +319,7 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
{/* 中:CM助手(落在背景图发光基座上,纯文字无图标) */}
<div className="header-center-title">CM助手</div>
<div className="header-center-title">CM业务中心</div>
{/* 右分区(flex-1):菜单靠左贴近中间基座,操作区靠最右 */}
<div className="flex h-full min-w-0 flex-1 items-center justify-between">
@@ -344,7 +345,7 @@ const Header = ({ collapsed }: HeaderProps) => {
<FileText className="h-4 w-4 text-white" />
</div>
<span className="whitespace-nowrap text-base font-semibold text-white">
CM助手
CM业务中心
</span>
</div>
<nav className="light-topnav" aria-label="一级菜单">
@@ -371,7 +372,7 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
{!collapsed && (
<span className="text-sm font-semibold text-white">
CM助手
CM业务中心
</span>
)}
</div>
@@ -387,7 +388,7 @@ const Header = ({ collapsed }: HeaderProps) => {
<nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list">
{thirdSubNavItems.map((item) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
return (
<button
@@ -9,6 +9,7 @@ import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/lig
/** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */
export const MOUNT_OPTIONS: { label: string; value: string }[] = [
{ label: "XK中任务", value: "xk-center-task" },
{ label: "COH中任务", value: "coh-center-task" },
{ label: "PG中任务", value: "pg-center-task" },
{ label: "TY中任务", value: "ty-center-task" },
{ label: "资源管理", value: "resource-management" },
+52 -1
View File
@@ -950,7 +950,10 @@ html.dark .strategy-app-header {
display: flex;
align-items: center;
justify-content: center;
padding: 0 155px;
/* 左右内边距随视口宽度等比缩放,与背景图基座的 background-size:cover 同步,
使两侧菜单在 1080p / 2K / 4K 下都贴齐发光基座边缘。
基准:1920 宽时 155px → 155/1920×100 ≈ 8.07vw。 */
padding: 0 8.07vw;
/* 文字略微上移,贴合基座视觉中心 */
transform: translateY(-6px);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese",
@@ -1013,3 +1016,51 @@ html.lightblue .workspace-assistant-card::before {
html.lightblue .tiptap-writing-editor .ProseMirror ::selection {
background: color-mix(in srgb, #55a6fe 35%, transparent);
}
/* ── 来源上传弹窗:A系统基因库的分段单选条(custom-radio) ─────────────── */
/* 外层容器,模拟 nz-radio-group */
.custom-radio-group {
display: inline-flex;
align-items: center;
font-size: 12px;
}
/* 单个按钮样式 */
.custom-radio-btn {
position: relative;
display: inline-block;
padding: 5px 16px;
line-height: 12px;
background: #fff;
border: 1px solid #d9d9d9;
cursor: pointer;
/* 除第一个外向左移 1px,实现边框重叠,消除双层边框 */
margin-left: -1px;
transition: all 0.1s ease;
user-select: none;
}
/* 第一个按钮:左侧圆角 */
.custom-radio-btn:first-child {
margin-left: 0;
border-radius: 4px 0 0 4px;
}
/* 最后一个按钮:右侧圆角 */
.custom-radio-btn:last-child {
border-radius: 0 4px 4px 0;
}
/* 悬浮状态 */
.custom-radio-btn:hover {
color: #1890ff;
z-index: 1;
}
/* 选中状态 */
.custom-radio-btn.active {
background-color: #e6f7ff;
border-color: #1890ff;
color: #1890ff;
z-index: 2;
}