merge: 合并 master 冲突,保留暗色主题 + 内嵌传参支持

- StudioNotebookDetailPage: 保留 notebookIdProp 内嵌传参 + useTheme 透传
- StudioNotebooksPage: 保留 onOpen 回调 + 暗色主题卡片样式

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Sonnet 4.6 committed 2026-06-17 15:12:08 +08:00
commit 719ff6f210
162 files changed
+9812 -352

No files matched your search

+1
View File
@@ -84,6 +84,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
### Configuration ### Configuration
+4 -1
View File
@@ -4,6 +4,9 @@
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 # 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
@@ -27,7 +30,7 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://localhost:8003 VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL) # 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间前端地址(用于 auth/callback 等跳转) # 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn # VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
+5
View File
@@ -33,6 +33,11 @@
# 仅本机 .env 默认 false(假数据)。改后需重启 dev / 重新打包生效。 # 仅本机 .env 默认 false(假数据)。改后需重启 dev / 重新打包生效。
# VITE_USE_REAL_DATA=true # VITE_USE_REAL_DATA=true
# --- 任务详情(登录深链 cop-task-detail)数据源开关 ---
# 默认真实接口(未设/false);改成 true 时该接口走本地假数据(consumer 外网不可达时调试用)。
# 与 VITE_USE_REAL_DATA 独立。改后需重启 dev / 重新打包生效。
# VITE_MOCK_COP_TASK_DETAIL=false
# MaxKey 鉴权根地址(无界 onlineTicket 换 token) # MaxKey 鉴权根地址(无界 onlineTicket 换 token)
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn # VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer # VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
+3
View File
@@ -4,6 +4,9 @@
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 # 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001 VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
+3
View File
@@ -4,6 +4,9 @@
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 # 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
+3
View File
@@ -4,6 +4,9 @@
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 # 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
+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() { export function App() {
return ( return (
<HashRouter> <HashRouter>
<AppProviders> <AppProviders>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/login" replace />} /> <Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} /> <Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} /> <Route path="/landing" element={<LandingPage />} />
@@ -0,0 +1,429 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
useExtractTemplateActions,
useExtractTemplates,
useFolderActions,
useKnowledgeFolders,
} from "@/core/knowledge/hooks";
import type {
ExtractTemplateScope,
KnowledgeExtractTemplate,
} from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
const NONE = "__none__";
/** A dropdown of managed directories (+ an "unfiled" option). */
export function FolderSelect({
value,
onChange,
disabled,
triggerClassName,
allowNone = true,
}: {
value: string | null;
onChange: (v: string | null) => void;
disabled?: boolean;
triggerClassName?: string;
allowNone?: boolean;
}) {
const { data: folders = [] } = useKnowledgeFolders();
return (
<Select
value={value ?? NONE}
onValueChange={(v) => onChange(v === NONE ? null : v)}
disabled={disabled}
>
<SelectTrigger className={cn("h-9", triggerClassName)}>
<SelectValue placeholder="选择目录" />
</SelectTrigger>
<SelectContent>
{allowNone ? <SelectItem value={NONE}>(不归类)</SelectItem> : null}
{folders.map((f) => (
<SelectItem key={f.id} value={f.path}>
{f.path}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** A dropdown of enabled extraction templates applicable to a scope. */
export function TemplateSelect({
value,
onChange,
scope,
disabled,
triggerClassName,
}: {
value: string | null;
onChange: (v: string | null) => void;
scope: ExtractTemplateScope;
disabled?: boolean;
triggerClassName?: string;
}) {
const { data: templates = [] } = useExtractTemplates(scope, true);
return (
<Select
value={value ?? NONE}
onValueChange={(v) => onChange(v === NONE ? null : v)}
disabled={disabled}
>
<SelectTrigger className={cn("h-9", triggerClassName)}>
<SelectValue placeholder="默认模板" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>默认模板</SelectItem>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
{t.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** Create / rename / delete knowledge directories. */
export function FolderManageDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { data: folders = [], isLoading } = useKnowledgeFolders();
const { create, rename, remove } = useFolderActions();
const [newPath, setNewPath] = useState("");
const handleCreate = async () => {
const path = newPath.trim();
if (!path) {
toast.error("请输入目录路径");
return;
}
try {
await create.mutateAsync(path);
toast.success("目录已创建");
setNewPath("");
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建失败");
}
};
const handleRename = async (folderId: string, current: string) => {
const next = window.prompt("重命名 / 移动目录(输入新的完整路径,斜杠分隔)", current);
if (next === null) return;
try {
await rename.mutateAsync({ folderId, path: next });
toast.success("已更新");
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新失败");
}
};
const handleDelete = async (folderId: string, path: string) => {
if (!window.confirm(`删除目录「${path}」?其中的知识将变为未归类(不会被删除)。`)) return;
try {
await remove.mutateAsync({ folderId });
toast.success("目录已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>目录管理</DialogTitle>
<DialogDescription>
自建知识目录,用斜杠分隔多级(如「投研/行业」)。删除目录只会让其中的知识变为未归类。
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Input
placeholder="新目录路径,如 投研/行业"
value={newPath}
onChange={(e) => setNewPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
/>
<Button onClick={() => void handleCreate()} disabled={create.isPending}>
<Plus className="size-4" /> 新建
</Button>
</div>
<ScrollArea className="max-h-[50vh]">
{isLoading ? (
<div className="text-muted-foreground p-3 text-sm">加载中...</div>
) : folders.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
还没有目录,先在上方新建一个。
</div>
) : (
<ul className="flex flex-col gap-1 p-1">
{folders.map((f) => (
<li
key={f.id}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm dark:border-slate-700"
>
<span className="truncate font-mono text-[13px]">{f.path}</span>
<div className="flex shrink-0 items-center gap-1">
<Button size="sm" variant="ghost" onClick={() => void handleRename(f.id, f.path)}>
重命名
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700"
onClick={() => void handleDelete(f.id, f.path)}
>
<Trash2 className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
const SCOPE_LABELS: Record<ExtractTemplateScope, string> = {
thread: "对话",
document: "文档",
both: "通用",
};
const EMPTY_DRAFT = {
name: "",
scope: "both" as ExtractTemplateScope,
description: "",
system_prompt: "",
is_default: false,
enabled: true,
};
/** Create / edit / delete the editable extraction-prompt templates. */
export function ExtractTemplateManageDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { data: templates = [], isLoading } = useExtractTemplates();
const { create, update, remove } = useExtractTemplateActions();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState({ ...EMPTY_DRAFT });
const selected = useMemo(
() => templates.find((t) => t.id === selectedId) ?? null,
[templates, selectedId],
);
// Load the selected template into the editable draft (or reset for "new").
useEffect(() => {
if (selected) {
setDraft({
name: selected.name,
scope: selected.scope,
description: selected.description ?? "",
system_prompt: selected.system_prompt,
is_default: selected.is_default,
enabled: selected.enabled,
});
} else {
setDraft({ ...EMPTY_DRAFT });
}
}, [selected]);
const startNew = () => setSelectedId(null);
const handleSave = async () => {
if (!draft.name.trim() || !draft.system_prompt.trim()) {
toast.error("请填写名称与提示词正文");
return;
}
try {
if (selectedId) {
await update.mutateAsync({ templateId: selectedId, data: draft });
toast.success("已保存");
} else {
const created = await create.mutateAsync(draft);
setSelectedId(created.id);
toast.success("模板已创建");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleDelete = async (t: KnowledgeExtractTemplate) => {
if (!window.confirm(`删除模板「${t.name}」?`)) return;
try {
await remove.mutateAsync(t.id);
if (selectedId === t.id) setSelectedId(null);
toast.success("已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-[95vw] sm:max-w-[1800px]">
<DialogHeader>
<DialogTitle>抽取提示词模板</DialogTitle>
<DialogDescription>
编辑驱动「知识沉淀」的提炼提示词,可新建多套抽取逻辑。沉淀对话/导入文件时可选用对应模板;未选则用该类型的默认模板。
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-[240px_minmax(0,1fr)] gap-4">
{/* template list */}
<div className="flex min-h-0 flex-col gap-2">
<Button size="sm" variant={selectedId === null ? "default" : "outline"} onClick={startNew}>
<Plus className="size-4" /> 新建模板
</Button>
<ScrollArea className="max-h-[50vh] rounded-md border dark:border-slate-700">
{isLoading ? (
<div className="text-muted-foreground p-3 text-sm">加载中...</div>
) : (
<ul className="flex flex-col">
{templates.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => setSelectedId(t.id)}
className={cn(
"flex w-full items-center justify-between gap-2 border-b px-3 py-2 text-left text-sm last:border-b-0 dark:border-slate-800",
selectedId === t.id ? "bg-slate-100 dark:bg-slate-800" : "hover:bg-slate-50 dark:hover:bg-slate-800/50",
)}
>
<span className="min-w-0">
<span className="block truncate">{t.name}</span>
<span className="text-muted-foreground text-[11px]">
{SCOPE_LABELS[t.scope]}
{t.is_default ? " · 默认" : ""}
{t.enabled ? "" : " · 已停用"}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</ScrollArea>
</div>
{/* editor */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Input
placeholder="模板名称"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
/>
<Select
value={draft.scope}
onValueChange={(v) => setDraft((d) => ({ ...d, scope: v as ExtractTemplateScope }))}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="both">通用</SelectItem>
<SelectItem value="thread">对话</SelectItem>
<SelectItem value="document">文档</SelectItem>
</SelectContent>
</Select>
</div>
<Input
placeholder="简介(可选)"
value={draft.description}
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
/>
<Textarea
rows={16}
placeholder="提示词正文(system prompt)。这里直接控制提炼粒度——想保留更多信息,就在此放宽要求、要求结构化更详细的输出。"
value={draft.system_prompt}
onChange={(e) => setDraft((d) => ({ ...d, system_prompt: e.target.value }))}
className="h-[50vh] max-h-[50vh] resize-none overflow-y-auto font-mono text-xs"
/>
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={draft.is_default}
onChange={(e) => setDraft((d) => ({ ...d, is_default: e.target.checked }))}
className="size-4"
/>
设为该类型默认
</label>
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => setDraft((d) => ({ ...d, enabled: e.target.checked }))}
className="size-4"
/>
启用
</label>
<div className="ml-auto flex items-center gap-2">
{selected ? (
<Button
variant="ghost"
className="text-red-600 hover:text-red-700"
onClick={() => void handleDelete(selected)}
>
<Trash2 className="size-4" /> 删除
</Button>
) : null}
<Button onClick={() => void handleSave()} disabled={create.isPending || update.isPending}>
{selectedId ? "保存" : "创建"}
</Button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -22,6 +22,7 @@ import type {
import { Breadcrumb } from "./Breadcrumb"; import { Breadcrumb } from "./Breadcrumb";
import { DocumentToolbar } from "./DocumentToolbar"; import { DocumentToolbar } from "./DocumentToolbar";
import { FolderSelect } from "./KnowledgeManage";
import { FloatingToc } from "./FloatingToc"; import { FloatingToc } from "./FloatingToc";
import { MarkdownViewer } from "./MarkdownViewer"; import { MarkdownViewer } from "./MarkdownViewer";
import { RelatedDocs } from "./RelatedDocs"; import { RelatedDocs } from "./RelatedDocs";
@@ -35,6 +36,7 @@ import {
downloadMarkdown, downloadMarkdown,
extractToc, extractToc,
formatDateTime, formatDateTime,
localizeHeadings,
noteBreadcrumbSegments, noteBreadcrumbSegments,
splitRelatedSection, splitRelatedSection,
} from "./wikiUtils"; } from "./wikiUtils";
@@ -102,10 +104,14 @@ export function WikiContent({
// Pull the backend-injected `## Related` wikilinks out of the body so they // Pull the backend-injected `## Related` wikilinks out of the body so they
// render as a structured module; editing still works on the full source. // render as a structured module; editing still works on the full source.
const { body, related } = useMemo( // After extraction, localize the English section headings (Context / Finding
// / Reasoning / …) to Chinese for display — `extractToc` and the viewer share
// this transformed text so heading anchors stay consistent.
const { body: rawBody, related } = useMemo(
() => splitRelatedSection(note.content_md), () => splitRelatedSection(note.content_md),
[note.content_md], [note.content_md],
); );
const body = useMemo(() => localizeHeadings(rawBody), [rawBody]);
const toc = useMemo(() => extractToc(body), [body]); const toc = useMemo(() => extractToc(body), [body]);
// The floating outline only shows h2/h3 — deeper levels are noise. // The floating outline only shows h2/h3 — deeper levels are noise.
const outlineItems = useMemo( const outlineItems = useMemo(
@@ -177,6 +183,15 @@ export function WikiContent({
} }
}; };
const handleMoveFolder = async (folder: string | null) => {
try {
await update.mutateAsync({ noteId: note.id, data: { folder } });
toast.success(folder ? `已移动到「${folder}」` : "已移出目录");
} catch (error) {
toast.error(error instanceof Error ? error.message : "移动失败");
}
};
const handleArchive = async () => { const handleArchive = async () => {
try { try {
await remove.mutateAsync({ noteId: note.id }); await remove.mutateAsync({ noteId: note.id });
@@ -277,6 +292,15 @@ export function WikiContent({
{note.created_at ? ( {note.created_at ? (
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag> <MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null} ) : null}
<span className="inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
目录
<FolderSelect
value={note.folder ?? null}
onChange={(v) => void handleMoveFolder(v)}
disabled={update.isPending}
triggerClassName="h-6 w-[10rem] text-xs"
/>
</span>
{note.tags.map((tag) => ( {note.tags.map((tag) => (
<button <button
key={tag} key={tag}
@@ -1,9 +1,11 @@
import { import {
Copy, Copy,
FolderTree,
Menu, Menu,
Network, Network,
Plus, Plus,
RefreshCw, RefreshCw,
SlidersHorizontal,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
@@ -48,6 +50,8 @@ export function WikiHeader({
onDedup, onDedup,
onGraph, onGraph,
onCreate, onCreate,
onManageFolders,
onManageTemplates,
}: { }: {
onToggleSidebar: () => void; onToggleSidebar: () => void;
onImportFile: () => void; onImportFile: () => void;
@@ -55,6 +59,8 @@ export function WikiHeader({
onDedup: () => void; onDedup: () => void;
onGraph: () => void; onGraph: () => void;
onCreate: () => void; onCreate: () => void;
onManageFolders: () => void;
onManageTemplates: () => void;
}) { }) {
return ( return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900"> <header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
@@ -83,6 +89,16 @@ export function WikiHeader({
label="批量导入" label="批量导入"
onClick={onBatchImport} onClick={onBatchImport}
/> />
<HeaderButton
icon={<FolderTree className="size-4" />}
label="目录管理"
onClick={onManageFolders}
/>
<HeaderButton
icon={<SlidersHorizontal className="size-4" />}
label="抽取模板"
onClick={onManageTemplates}
/>
<HeaderButton <HeaderButton
icon={<Copy className="size-4" />} icon={<Copy className="size-4" />}
label="查重" label="查重"
@@ -14,4 +14,6 @@ export interface TreeNode {
type: "folder" | "file"; type: "folder" | "file";
children?: TreeNode[]; children?: TreeNode[];
item?: KnowledgeNote; item?: KnowledgeNote;
/** Full slash-joined directory path (folder nodes only). */
folderPath?: string;
} }
+92 -12
View File
@@ -19,6 +19,73 @@ export const STATUS_LABELS: Record<string, string> = {
const FALLBACK_FOLDER = "未分类"; const FALLBACK_FOLDER = "未分类";
/**
* Display labels for known English directory segments (the wiki-capture
* categories that historically drove the vault path). Keyed by the lowercased
* segment. User-created folders are arbitrary (usually Chinese) and simply don't
* match, so they pass through unchanged.
*/
const FOLDER_LABELS: Record<string, string> = {
synthesis: "分析综合",
concepts: "概念框架",
concept: "概念框架",
references: "外部来源",
reference: "外部来源",
decision: "决策记录",
journal: "会话纪要",
session: "会话纪要",
};
/** Map a known English directory segment to its Chinese display label (else unchanged). */
export function localizeFolderName(name: string): string {
return FOLDER_LABELS[name.trim().toLowerCase()] ?? name;
}
/**
* Canonical English wiki-capture section headings → Chinese display labels.
* Keyed by the lowercased, symbol-stripped heading text.
*/
const HEADING_LABELS: Record<string, string> = {
context: "背景",
"finding / decision": "结论 / 决策",
"finding/decision": "结论 / 决策",
finding: "结论",
decision: "决策",
reasoning: "推理依据",
implications: "影响 / 后续",
sources: "来源",
related: "相关知识",
merged: "已合并",
};
/**
* Replace the canonical English wiki-capture section headings (`## Context`,
* `## Finding / Decision`, …) with Chinese labels at render time. Operates on
* heading lines only (fenced code blocks are skipped) and matches the heading
* text case-insensitively, so **all historical notes are localized with no data
* migration**. Heading levels are preserved; because `extractToc` and the
* markdown viewer both receive this same transformed text, the slug/anchor ids
* stay consistent.
*/
export function localizeHeadings(markdown: string): string {
let inFence = false;
return markdown
.split("\n")
.map((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
const match = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
if (!match) return line;
const key = match[2].replace(/[#`*_~]/g, "").trim().toLowerCase();
const zh = HEADING_LABELS[key];
return zh ? `${match[1]} ${zh}` : line;
})
.join("\n");
}
/** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */ /** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
export function slugify(text: string): string { export function slugify(text: string): string {
return text return text
@@ -119,16 +186,17 @@ export function splitRelatedSection(markdown: string): {
return { body, related }; return { body, related };
} }
/** Folder path segments for a note: vault_path directories, else its source-type group. */ /**
* Folder path segments for a note. The user-assigned ``folder`` (a slash-joined
* path) is authoritative; unfiled notes fall back to a source-type group so the
* tree is never empty. (The old behavior keyed off ``vault_path`` — the fixed
* ``projects/<project>/<category>`` scaffold — which the editable directory now
* replaces.)
*/
export function noteFolderSegments(note: KnowledgeNote): string[] { export function noteFolderSegments(note: KnowledgeNote): string[] {
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, ""); const folder = (note.folder || "").replace(/^\/+|\/+$/g, "").trim();
if (rawPath && rawPath.includes("/")) { if (folder) {
let segments = rawPath.split("/").slice(0, -1).filter(Boolean); const segments = folder.split("/").map((s) => s.trim()).filter(Boolean);
// Vault notes live under projects/<project>/<category>/ — the first two
// levels are constant scaffolding, so hide them from the directory tree.
if (segments[0] === "projects" || segments[0] === "project") {
segments = segments.slice(2);
}
if (segments.length > 0) return segments; if (segments.length > 0) return segments;
} }
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER]; return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER];
@@ -136,7 +204,7 @@ export function noteFolderSegments(note: KnowledgeNote): string[] {
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */ /** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] { export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
return ["问答梳理", ...noteFolderSegments(note)]; return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
} }
function sortTree(nodes: TreeNode[]): void { function sortTree(nodes: TreeNode[]): void {
@@ -153,7 +221,10 @@ function sortTree(nodes: TreeNode[]): void {
* Build the directory tree from the flat note list. Notes with a vault_path * Build the directory tree from the flat note list. Notes with a vault_path
* become nested folders mirroring that path; others are grouped by source type. * become nested folders mirroring that path; others are grouped by source type.
*/ */
export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] { export function buildKnowledgeTree(
notes: KnowledgeNote[],
folderPaths: string[] = [],
): TreeNode[] {
const root: TreeNode[] = []; const root: TreeNode[] = [];
const folderMap = new Map<string, TreeNode>(); const folderMap = new Map<string, TreeNode>();
@@ -164,7 +235,9 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
path = path ? `${path}/${segment}` : segment; path = path ? `${path}/${segment}` : segment;
let node = folderMap.get(path); let node = folderMap.get(path);
if (!node) { if (!node) {
node = { id: `folder:${path}`, name: segment, type: "folder", children: [] }; // Display name is localized (known English category → 中文); the id /
// folderPath keep the raw segment so filtering/matching is unaffected.
node = { id: `folder:${path}`, name: localizeFolderName(segment), type: "folder", children: [], folderPath: path };
folderMap.set(path, node); folderMap.set(path, node);
children.push(node); children.push(node);
} }
@@ -173,6 +246,13 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
return children; return children;
}; };
// Pre-create managed (possibly empty) directories so a freshly-made folder
// shows up in the tree before any note is filed into it.
for (const path of folderPaths) {
const segments = path.split("/").map((s) => s.trim()).filter(Boolean);
if (segments.length) ensureFolder(segments);
}
for (const note of notes) { for (const note of notes) {
ensureFolder(noteFolderSegments(note)).push({ ensureFolder(noteFolderSegments(note)).push({
id: note.id, id: note.id,
@@ -0,0 +1,479 @@
"use client";
import {
CheckIcon,
Loader2,
PencilIcon,
PlusIcon,
RefreshCwIcon,
Trash2Icon,
UserPlusIcon,
UsersIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { resolveTagColor } from "@/components/workspace/tags/tag-colors";
import {
POSITION_RESOURCE_HINT,
POSITION_RESOURCE_LABELS,
POSITION_RESOURCE_TYPES,
resourceTypeToTagType,
useAddMembers,
useCreatePosition,
useDeletePosition,
usePositionMembers,
usePositionTagBindings,
usePositionUsers,
usePositions,
useRemoveMembers,
useResyncPosition,
useSetTagBindings,
useUpdatePosition,
type Position,
type PositionResourceType,
type TagBindings,
} from "@/core/positions";
import { useTags } from "@/core/tags";
import { cn } from "@/lib/utils";
function errMsg(e: unknown): string {
return String(e instanceof Error ? e.message : e);
}
/** Inline multi-select chip row for the tags of one resource type. */
function TagMultiSelect({
resourceType,
value,
onChange,
}: {
resourceType: PositionResourceType;
value: string[];
onChange: (next: string[]) => void;
}) {
const { data: tags, isLoading } = useTags(resourceTypeToTagType(resourceType));
const toggle = (id: string) =>
onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]);
if (isLoading) {
return <Loader2 className="text-muted-foreground size-4 animate-spin" />;
}
if (!tags || tags.length === 0) {
return (
<span className="text-muted-foreground text-xs">
暂无{POSITION_RESOURCE_LABELS[resourceType]}标签,请先到「标签管理」创建。
</span>
);
}
return (
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => {
const checked = value.includes(tag.id);
const color = resolveTagColor(tag.color);
return (
<button
key={tag.id}
type="button"
onClick={() => toggle(tag.id)}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all",
color.chip,
checked ? "ring-foreground/40 ring-2 ring-offset-1" : "opacity-45 hover:opacity-90",
)}
>
{checked && <CheckIcon className="size-3" />}
{tag.name}
</button>
);
})}
</div>
);
}
function PositionFormDialog({
open,
onOpenChange,
editing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Position | null;
}) {
const createPosition = useCreatePosition();
const updatePosition = useUpdatePosition();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isDefault, setIsDefault] = useState(false);
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${editing?.id ?? "new"}`;
if (open && syncedFor !== syncKey) {
setName(editing?.name ?? "");
setDescription(editing?.description ?? "");
setIsDefault(editing?.is_default ?? false);
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
const pending = createPosition.isPending || updatePosition.isPending;
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
try {
if (editing) {
await updatePosition.mutateAsync({ id: editing.id, input: { name: trimmed, description, is_default: isDefault } });
} else {
await createPosition.mutateAsync({ name: trimmed, description, is_default: isDefault });
}
onOpenChange(false);
} catch (e) {
toast.error(errMsg(e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "编辑岗位" : "新增岗位"}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">岗位名称</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:理赔专员" maxLength={191} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">描述</label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} maxLength={1024} />
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
设为默认岗位(未分配岗位的用户继承此岗位的配置)
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={pending || !name.trim()}>
{pending && <Loader2 className="size-4 animate-spin" />}保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function AddMembersDialog({
open,
onOpenChange,
positionId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
positionId: string;
}) {
const { data: users, isLoading } = usePositionUsers();
const addMembers = useAddMembers();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
useEffect(() => {
if (open) setSelected(new Set());
}, [open]);
const filtered = useMemo(() => {
const list = users ?? [];
const q = search.trim().toLowerCase();
return q ? list.filter((u) => u.email.toLowerCase().includes(q)) : list;
}, [users, search]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSave = async () => {
if (selected.size === 0) return;
try {
await addMembers.mutateAsync({ positionId, userIds: [...selected] });
toast.success(`已为 ${selected.size} 名用户配置岗位`);
onOpenChange(false);
} catch (e) {
toast.error(errMsg(e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>批量添加成员</DialogTitle>
</DialogHeader>
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索邮箱…" />
<div className="max-h-72 overflow-y-auto rounded-md border">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-6">
<Loader2 className="size-4 animate-spin" />加载用户…
</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="text-muted-foreground py-6 text-center text-sm">无匹配用户</div>
)}
{filtered.map((u) => {
const checked = selected.has(u.id);
return (
<button
key={u.id}
type="button"
onClick={() => toggle(u.id)}
className={cn(
"flex w-full items-center justify-between gap-2 border-b px-3 py-2 text-left text-sm last:border-b-0 hover:bg-muted/50",
checked && "bg-muted/40",
)}
>
<span className="truncate">
{u.email}
{u.system_role === "admin" && <span className="text-muted-foreground ml-2 text-xs">(管理员)</span>}
</span>
<span className="flex items-center gap-2">
{u.position_id && u.position_id !== positionId && (
<span className="text-muted-foreground text-xs">已属其他岗位</span>
)}
<span className={cn("flex size-4 items-center justify-center rounded border", checked ? "bg-foreground text-background border-foreground" : "border-muted-foreground/40")}>
{checked && <CheckIcon className="size-3" />}
</span>
</span>
</button>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={addMembers.isPending || selected.size === 0}>
{addMembers.isPending && <Loader2 className="size-4 animate-spin" />}添加 {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function PositionDetail({ position }: { position: Position }) {
const { data: serverBindings } = usePositionTagBindings(position.id);
const { data: members } = usePositionMembers(position.id);
const setBindings = useSetTagBindings();
const removeMembers = useRemoveMembers();
const resync = useResyncPosition();
const [draft, setDraft] = useState<TagBindings>({});
const [addOpen, setAddOpen] = useState(false);
// Reload the draft whenever the selected position or its server bindings change.
const [loadedFor, setLoadedFor] = useState<string | null>(null);
const loadKey = `${position.id}:${serverBindings ? "ready" : "loading"}`;
if (serverBindings && loadedFor !== loadKey) {
setDraft({ ...serverBindings });
setLoadedFor(loadKey);
}
const setType = (rt: PositionResourceType, ids: string[]) =>
setDraft((prev) => ({ ...prev, [rt]: ids }));
const handleSaveBindings = async () => {
try {
await setBindings.mutateAsync({ positionId: position.id, bindings: draft });
toast.success("标签配置已保存,已同步岗位成员");
} catch (e) {
toast.error(errMsg(e));
}
};
const handleResync = async () => {
try {
const n = await resync.mutateAsync(position.id);
toast.success(`已重新同步 ${n} 名成员`);
} catch (e) {
toast.error(errMsg(e));
}
};
const handleRemove = async (userId: string) => {
try {
await removeMembers.mutateAsync({ positionId: position.id, userIds: [userId] });
} catch (e) {
toast.error(errMsg(e));
}
};
return (
<div className="flex flex-col gap-6">
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold">默认资源标签配置</h3>
<Button size="sm" onClick={handleSaveBindings} disabled={setBindings.isPending}>
{setBindings.isPending && <Loader2 className="size-4 animate-spin" />}保存标签配置
</Button>
</div>
<div className="flex flex-col divide-y">
{POSITION_RESOURCE_TYPES.map((rt) => (
<div key={rt} className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium">{POSITION_RESOURCE_LABELS[rt]}</span>
<span className="text-muted-foreground text-xs">{POSITION_RESOURCE_HINT[rt]}</span>
</div>
<TagMultiSelect resourceType={rt} value={draft[rt] ?? []} onChange={(ids) => setType(rt, ids)} />
</div>
))}
</div>
</div>
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-1.5 text-sm font-semibold">
<UsersIcon className="size-4" />岗位成员 ({members?.length ?? 0})
</h3>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleResync} disabled={resync.isPending}>
{resync.isPending ? <Loader2 className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}重新同步
</Button>
<Button size="sm" onClick={() => setAddOpen(true)}>
<UserPlusIcon className="size-4" />添加成员
</Button>
</div>
</div>
{members && members.length > 0 ? (
<div className="flex flex-col divide-y">
{members.map((m) => (
<div key={m.id} className="flex items-center justify-between py-2 text-sm">
<span className="truncate">{m.email}</span>
<Button variant="ghost" size="icon" onClick={() => handleRemove(m.id)} aria-label="移除">
<Trash2Icon className="text-destructive size-4" />
</Button>
</div>
))}
</div>
) : (
<div className="text-muted-foreground py-6 text-center text-sm">暂无成员</div>
)}
</div>
<AddMembersDialog open={addOpen} onOpenChange={setAddOpen} positionId={position.id} />
</div>
);
}
export function PositionManagementPage() {
const { data: positions, isLoading, error } = usePositions();
const deletePosition = useDeletePosition();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Position | null>(null);
const selected = useMemo(
() => positions?.find((p) => p.id === selectedId) ?? null,
[positions, selectedId],
);
// Auto-select the first position once loaded.
useEffect(() => {
if (!selectedId && positions && positions.length > 0) setSelectedId(positions[0].id);
}, [positions, selectedId]);
const handleDelete = async (p: Position) => {
if (!window.confirm(`确认删除岗位「${p.name}」?其成员将回退到默认岗位。`)) return;
try {
await deletePosition.mutateAsync(p.id);
if (selectedId === p.id) setSelectedId(null);
} catch (e) {
toast.error(errMsg(e));
}
};
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">岗位管理</h1>
<p className="text-muted-foreground text-sm">
为岗位配置默认智能体/技能/定时任务/推荐问题的标签,并批量分配给用户。
</p>
</div>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<PlusIcon className="size-4" />新增岗位
</Button>
</div>
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12">
<Loader2 className="size-4 animate-spin" />加载中…
</div>
)}
{error && <div className="text-destructive text-sm">{errMsg(error)}</div>}
{!isLoading && !error && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
<div className="flex flex-col gap-2">
{positions && positions.length === 0 && (
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
还没有岗位,点击「新增岗位」创建。
</div>
)}
{positions?.map((p) => (
<div
key={p.id}
onClick={() => setSelectedId(p.id)}
className={cn(
"group cursor-pointer rounded-lg border p-3 transition-colors",
selectedId === p.id ? "border-foreground bg-muted/40" : "hover:bg-muted/30",
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{p.name}</span>
{p.is_default && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-950 dark:text-blue-300">默认</span>
)}
</div>
<div className="flex opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" onClick={(e) => { e.stopPropagation(); setEditing(p); setDialogOpen(true); }} aria-label="编辑">
<PencilIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" onClick={(e) => { e.stopPropagation(); void handleDelete(p); }} aria-label="删除">
<Trash2Icon className="text-destructive size-3.5" />
</Button>
</div>
</div>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<UsersIcon className="size-3" />{p.member_count} 名成员
</div>
</div>
))}
</div>
<div>
{selected ? (
<PositionDetail key={selected.id} position={selected} />
) : (
<div className="text-muted-foreground rounded-lg border border-dashed py-16 text-center text-sm">
选择左侧一个岗位以配置标签与成员
</div>
)}
</div>
</div>
)}
<PositionFormDialog open={dialogOpen} onOpenChange={setDialogOpen} editing={editing} />
</div>
);
}
@@ -27,6 +27,7 @@ import {
type Tag, type Tag,
type TagType, type TagType,
} from "@/core/tags"; } from "@/core/tags";
import { TAG_TYPES, TAG_TYPE_LABELS } from "@/core/tags/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function TagFormDialog({ function TagFormDialog({
@@ -191,11 +192,11 @@ export function TagManagementPage() {
onValueChange={(v) => setActiveType(v as TagType)} onValueChange={(v) => setActiveType(v as TagType)}
> >
<TabsList variant="line"> <TabsList variant="line">
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger> {TAG_TYPES.map((tt) => (
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger> <TabsTrigger key={tt} value={tt}>
<TabsTrigger value="scheduled_task"> {TAG_TYPE_LABELS[tt]}
{t.adminTags.typeScheduledTask} </TabsTrigger>
</TabsTrigger> ))}
</TabsList> </TabsList>
</Tabs> </Tabs>
<Button size="sm" onClick={openCreate}> <Button size="sm" onClick={openCreate}>
@@ -2,6 +2,7 @@
import { import {
BotIcon, BotIcon,
ClockIcon,
CopyIcon, CopyIcon,
EyeOffIcon, EyeOffIcon,
GlobeIcon, GlobeIcon,
@@ -78,6 +79,7 @@ import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { formatDateTime } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -605,6 +607,17 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</button> </button>
) : null; ) : null;
const createdAtText = formatDateTime(agent.created_at);
const createdAtNode = createdAtText ? (
<div
className="text-muted-foreground flex max-w-full items-center gap-1 text-xs"
title={`创建时间:${createdAtText}`}
>
<ClockIcon className="size-3.5 shrink-0" />
<span className="truncate">创建时间:{createdAtText}</span>
</div>
) : null;
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。 // 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
const skillNameZhMap = useMemo(() => { const skillNameZhMap = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -820,6 +833,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</CardDescription> </CardDescription>
)} )}
{publisherNode && <div className="mt-2">{publisherNode}</div>} {publisherNode && <div className="mt-2">{publisherNode}</div>}
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
</CardHeader> </CardHeader>
{(agent.tags?.length ?? 0) > 0 && ( {(agent.tags?.length ?? 0) > 0 && (
@@ -866,6 +880,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</p> </p>
)} )}
{publisherNode && <div className="mt-0.5">{publisherNode}</div>} {publisherNode && <div className="mt-0.5">{publisherNode}</div>}
{createdAtNode && <div className="mt-0.5">{createdAtNode}</div>}
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons} {showActionRow && actionButtons}
@@ -187,23 +187,30 @@ export function AgentDetailPanel({
const isSaving = updateAgent.isPending || setFeatured.isPending; const isSaving = updateAgent.isPending || setFeatured.isPending;
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体 // 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
// 信息面板给预览让位;新文件落地时同样收起。编辑中不打断用户,用户随时 // 信息面板给预览让位;切换到新文件时同样收起。编辑中不打断用户,用户随时
// 可手动重新展开。 // 可手动重新展开。
//
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
// 该文件要等生成结束、artifact 入库后才会进 thread.values.artifacts。若按
// 数量判定就只能等生成结束才收起——正是之前「生成完才收起」的 bug 根因。
const artifactsCtx = useArtifactsOptional(); const artifactsCtx = useArtifactsOptional();
const artifactCount = artifactsCtx?.artifacts.length ?? 0; const artifactCount = artifactsCtx?.artifacts.length ?? 0;
const artifactPanelVisible = !!artifactsCtx?.open && artifactCount > 0; const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
const artifactPanelVisible =
!!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0);
const prevArtifactPanelVisible = useRef(false); const prevArtifactPanelVisible = useRef(false);
const prevArtifactCount = useRef(0); const prevSelectedArtifact = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current; const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
const grew = const switched =
artifactPanelVisible && artifactCount > prevArtifactCount.current; artifactPanelVisible && selectedArtifact !== prevSelectedArtifact.current;
prevArtifactPanelVisible.current = artifactPanelVisible; prevArtifactPanelVisible.current = artifactPanelVisible;
prevArtifactCount.current = artifactCount; prevSelectedArtifact.current = selectedArtifact;
if ((opened || grew) && !editing) { if ((opened || switched) && !editing) {
setCollapsed(true); setCollapsed(true);
} }
}, [artifactPanelVisible, artifactCount, editing]); }, [artifactPanelVisible, selectedArtifact, editing]);
const displayName = desensitize(agent?.name ?? agentName); const displayName = desensitize(agent?.name ?? agentName);
const questions = useMemo( const questions = useMemo(
@@ -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"; } from "@/components/ui/dialog";
import { PromptInputButton } from "@/components/ai-elements/prompt-input"; import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog"; 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 type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface AgentSelectorProps { interface AgentSelectorProps {
@@ -51,12 +52,21 @@ export function AgentSelector({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false);
const { agents, isLoading } = useAgentSelector(); 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 isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const selectedAgent = useMemo<Agent | null>(() => { const selectedAgent = useMemo<Agent | null>(() => {
if (!selectedAgentId) return null; if (!selectedAgentId) return null;
return agents.find((a) => a.id === selectedAgentId) ?? null; return (
}, [agents, selectedAgentId]); [...agents, ...mineAgents, ...squareAgents].find(
(a) => a.id === selectedAgentId,
) ?? null
);
}, [agents, mineAgents, squareAgents, selectedAgentId]);
const handleSelect = (agent: Agent) => { const handleSelect = (agent: Agent) => {
onAgentChange(agent.id); onAgentChange(agent.id);
@@ -68,7 +78,7 @@ export function AgentSelector({
onAgentChange(null); onAgentChange(null);
}; };
const triggerLabel = selectedAgent?.name ?? "选择 Agent"; const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
return ( return (
<> <>
@@ -125,7 +135,7 @@ export function AgentSelector({
{agents.map((agent) => ( {agents.map((agent) => (
<CommandItem <CommandItem
key={agent.id} key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`} value={`${desensitize(agent.name)} ${desensitize(agent.description ?? "")}`}
onSelect={() => handleSelect(agent)} onSelect={() => handleSelect(agent)}
className={cn( className={cn(
"flex w-full min-w-0 items-start gap-2 overflow-hidden", "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" /> <BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col"> <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 && ( {agent.description && (
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground"> <span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
{agent.description} {desensitize(agent.description)}
</span> </span>
)} )}
</div> </div>
@@ -122,6 +122,7 @@ export function ArtifactFileDetail({
threadId, threadId,
onClose, onClose,
allowVirtualMarkdownEdit = false, allowVirtualMarkdownEdit = false,
disableDesensitize = false,
}: { }: {
className?: string; className?: string;
filepath: string; filepath: string;
@@ -134,6 +135,12 @@ export function ArtifactFileDetail({
* 默认 false —— 主聊天页等其它调用方行为不变。 * 默认 false —— 主聊天页等其它调用方行为不变。
*/ */
allowVirtualMarkdownEdit?: boolean; allowVirtualMarkdownEdit?: boolean;
/**
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
* 默认 false,沿用全局脱敏。
*/
disableDesensitize?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
@@ -802,7 +809,7 @@ export function ArtifactFileDetail({
language === "html" || language === "html" ||
language === "mermaid") && ( language === "mermaid") && (
<ArtifactFilePreview <ArtifactFilePreview
content={desensitize(displayContent ?? "")} content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
language={language ?? "text"} language={language ?? "text"}
threadId={threadId} threadId={threadId}
publicShareCode={publicShareCode} publicShareCode={publicShareCode}
@@ -815,7 +822,7 @@ export function ArtifactFileDetail({
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent" className="size-full resize-none rounded-none border-none bg-transparent"
value={ value={
canEditArtifact canEditArtifact || disableDesensitize
? (displayContent ?? "") ? (displayContent ?? "")
: desensitize(displayContent ?? "") : desensitize(displayContent ?? "")
} }
@@ -0,0 +1,603 @@
"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 { useTheme } from "@/contexts/ThemeContext";
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 稳定散列到一组主题色,复刻参考图的彩色方块。
// 浅色模式沿用原高彩度彩色(不改);深蓝主题改用《深蓝 UI 规范》§1.1.7/§1.1.8 的蓝-青系
// (docs/ui规范-深蓝,克制、不刺眼),仅深蓝主题下生效,互不影响。
const AGENT_COLORS_LIGHT = [
{ from: "#fb7185", to: "#ef4444" },
{ from: "#60a5fa", to: "#3b82f6" },
{ from: "#fbbf24", to: "#f59e0b" },
{ from: "#34d399", to: "#10b981" },
{ from: "#a78bfa", to: "#8b5cf6" },
] as const;
const AGENT_COLORS_DARK = [
{ from: "#4096FF", to: "#1677FF" }, // 蓝(链接蓝 → 主色)
{ from: "#36CFC9", to: "#13C2C2" }, // 青(辅色)
{ from: "#6388FF", to: "#0958D9" }, // 紫蓝(装饰紫 → 主色深一档)
{ from: "#5CDBD3", to: "#08979C" }, // 浅青 → 深青
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
] as const;
function agentColor(id: string, isDark: boolean) {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
return palette[hash % palette.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 { theme } = useTheme();
const isDark = theme === "dark";
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 (
// 背景走 sidebar token(浅主题=白、深蓝主题=深蓝),不要写死 bg-white,否则全量版深蓝主题下侧栏仍是白底。
<div className="flex h-full min-h-0 flex-col bg-sidebar text-sidebar-foreground">
{/* ── 顶部:新对话胶囊 + 收起按钮 ── */}
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
{/* 新对话按钮:完全自定义样式(不复用 Button variant)。borderColor / color 走
inline style,避免被全局 border-color(--border)覆盖;无阴影。主色描边 + 5px 圆角。
底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应
(不要混 white,否则深蓝主题下会变成浅色块)。 */}
<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)_14%,transparent)] hover:bg-[color-mix(in_srgb,var(--primary)_24%,transparent)]",
)}
>
<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, isDark);
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>
);
}
@@ -10,6 +10,7 @@ import {
LightbulbIcon, LightbulbIcon,
PaperclipIcon, PaperclipIcon,
PlusIcon, PlusIcon,
ShrinkIcon,
SparklesIcon, SparklesIcon,
RocketIcon, RocketIcon,
XIcon, XIcon,
@@ -329,6 +330,14 @@ export function InputBox({
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, ragModeLocked, setLocalSettings]); }, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
const handleContextCompressionToggle = useCallback(() => {
setLocalSettings("display", {
contextCompressionEnabled: !contextCompressionEnabled,
});
}, [contextCompressionEnabled, setLocalSettings]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (status === "streaming") { if (status === "streaming") {
@@ -526,24 +535,12 @@ export function InputBox({
)} )}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && ( {isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
<div className={cn(compact && "-mt-2 flex flex-col gap-3")}>
{compact && (
<div className="text-muted-foreground flex items-center justify-center gap-1.5 text-xs font-medium">
<SparklesIcon className="size-3 text-[var(--primary)]" />
<span>试试这些高频业务场景</span>
</div>
)}
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]"> <div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => ( {recommendedQuestions.map((q) => (
<button <button
key={q.id} key={q.id}
type="button" type="button"
className={cn( className="rounded-full border border-[#e4e4e7] bg-white px-4 py-2 text-sm text-[#18181b] shadow-none transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
"rounded-full border border-[#e4e4e7] bg-white px-4 py-2 text-sm text-[#18181b] shadow-none transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
// 紧凑模式(CM 参考图,F12 精确还原):白底灰字灰边 + 主色 hover
compact &&
"px-4 py-2 text-xs font-medium text-gray-700 border-gray-100 shadow-xs transition-all duration-200 hover:bg-[color-mix(in_srgb,var(--primary)_3%,white)] hover:text-[var(--primary)] hover:border-[color-mix(in_srgb,var(--primary)_22%,transparent)] dark:text-slate-300 dark:border-slate-600 dark:hover:text-[var(--primary)]",
)}
onClick={() => { onClick={() => {
textInput.setInput(q.content); textInput.setInput(q.content);
setFollowupsHidden(true); setFollowupsHidden(true);
@@ -554,7 +551,6 @@ export function InputBox({
</button> </button>
))} ))}
</div> </div>
</div>
)} )}
{showFollowups && ( {showFollowups && (
@@ -748,6 +744,35 @@ export function InputBox({
<span className="text-xs font-normal">参考文献</span> <span className="text-xs font-normal">参考文献</span>
</PromptInputButton> </PromptInputButton>
</Tooltip> </Tooltip>
<Tooltip
content={
contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
: "上下文压缩已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={contextCompressionEnabled}
disabled={disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
contextCompressionEnabled
? "border border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleContextCompressionToggle}
>
{contextCompressionEnabled && (
<CheckIcon className="size-3 text-violet-700 dark:text-violet-200" />
)}
<ShrinkIcon className="size-3" />
<span className="text-xs font-normal">上下文压缩</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu> <PromptInputActionMenu>
<ModeHoverGuide <ModeHoverGuide
mode={ mode={
@@ -7,6 +7,7 @@ import { useStickToBottomContext } from "use-stick-to-bottom";
import { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -94,6 +95,7 @@ export function MessageList({
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false, tokenUsageEnabled = false,
extraRehypePlugins, extraRehypePlugins,
scrollButtonClassName,
onClarificationSubmit, onClarificationSubmit,
onEditResubmit, onEditResubmit,
onSetupAgentApprove, onSetupAgentApprove,
@@ -108,6 +110,8 @@ export function MessageList({
thread: BaseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
paddingBottom?: number; paddingBottom?: number;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>; extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>; onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void; onEditResubmit?: (text: string) => void;
@@ -577,6 +581,16 @@ export function MessageList({
<div style={{ height: `${paddingBottom}px` }} /> <div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider> </MessageMetricsProvider>
</ConversationContent> </ConversationContent>
{/* ChatGPT 式「回到最新」悬浮钮:仅在向上滚离底部时出现,点击平滑回到
最新问答。默认贴底,调用方可用 scrollButtonClassName 上移避开输入框。 */}
<ConversationScrollButton
className={cn(
"pointer-events-auto z-20 size-9 shadow-md",
scrollButtonClassName,
)}
aria-label="回到最新"
title="回到最新"
/>
</Conversation> </Conversation>
); );
} }
@@ -35,7 +35,7 @@ import { cn } from "@/lib/utils";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage"; import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage"; import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
@@ -457,14 +457,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
{isAdmin && ( {isAdmin && (
<> <>
<Separator />
<RecommendedQuestionsPage embedded />
<Separator /> <Separator />
<PromptPrefixSettingsPage embedded /> <PromptPrefixSettingsPage embedded />
<Separator /> <Separator />
<PrivateSquareSettingsPage embedded /> <PrivateSquareSettingsPage embedded />
<Separator /> <Separator />
<PublishSquaresSettingsPage embedded /> <PublishSquaresSettingsPage embedded />
<Separator />
<SkillCompressionSettingsPage embedded />
</> </>
)} )}
</div> </div>
@@ -2,6 +2,7 @@
import { import {
AlertCircle, AlertCircle,
Anchor,
ArchiveIcon, ArchiveIcon,
ArrowDownUpIcon, ArrowDownUpIcon,
BookmarkIcon, BookmarkIcon,
@@ -77,6 +78,7 @@ import {
useGenerateSkillNameZh, useGenerateSkillNameZh,
useInstallSkillUpload, useInstallSkillUpload,
useReconcileCustomSkills, useReconcileCustomSkills,
useSetSkillAlwaysOn,
useSetSkillFeatured, useSetSkillFeatured,
useSetSkillNameZh, useSetSkillNameZh,
useSetSkillPinned, useSetSkillPinned,
@@ -84,8 +86,13 @@ import {
useSkills, useSkills,
useValidateSkillUpload, useValidateSkillUpload,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
import { usePublishSquares, useViewMode } from "@/core/system-settings/hooks"; import {
usePublishSquares,
useSkillCompressionSettings,
useViewMode,
} from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { formatDateTime } from "@/core/utils/datetime";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -175,13 +182,22 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const fillNamesMutation = useGenerateMissingSkillNameZh(); const fillNamesMutation = useGenerateMissingSkillNameZh();
// Optional model override for the admin "一键补充中文名" action (null = default). // Optional model override for the admin "一键补充中文名" action (null = default).
const [fillModel, setFillModel] = useState<string | null>(null); const [fillModel, setFillModel] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
const storedAuth = useMemo(() => getStoredAuth(), []); const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null; const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin"; const isAdmin = storedAuth?.system_role === "admin";
// Name + tag filtering is resolved server-side via the /api/skills query.
// A 岗位 (position) no longer *restricts* the list — it grants skills into the
// member's 我的技能 (each such skill comes back with ``favorited: true``), so
// the 广场 always shows every visible skill and the 我的 tab includes the
// position-granted ones. ``scope`` is kept for API compatibility only.
const { skills, isLoading, error } = useSkills({
search,
tagIds,
sort,
scope: isAdmin ? undefined : "mine",
});
// 把可见技能分类成 UI 关心的几个桶。 // 把可见技能分类成 UI 关心的几个桶。
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。 // "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。 // "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
@@ -209,7 +225,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
if (isBuiltin || !!skill.published) { if (isBuiltin || !!skill.published) {
all.push(skill); all.push(skill);
} }
if (isOwnedByMe) { // "我的" = skills I own OR skills granted into my collection by my 岗位
// (position) — mirroring how favorited agents land in 我的智能体.
if (isOwnedByMe || !!skill.favorited) {
mine.push(skill); mine.push(skill);
} }
} }
@@ -593,6 +611,8 @@ export function SkillRow({
const { data: cardPublishSquares } = usePublishSquares(); const { data: cardPublishSquares } = usePublishSquares();
const setPinned = useSetSkillPinned(); const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured(); const setFeatured = useSetSkillFeatured();
const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings();
const deleteSkill = useDeleteCustomSkill(); const deleteSkill = useDeleteCustomSkill();
const duplicateSkillMutation = useDuplicateSkill(); const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
@@ -612,6 +632,17 @@ export function SkillRow({
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own. // Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin); const canManage = isCustom && (isOwned || isAdmin);
const canTogglePublish = canManage && !isBuiltin; const canTogglePublish = canManage && !isBuiltin;
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
const canSetAlwaysOn = isAdmin || isOwned;
async function handleToggleAlwaysOn(next: boolean) {
try {
await setAlwaysOn.mutateAsync({ skillName: skill.name, alwaysOn: next });
toast.success(next ? "已设为常驻(免压缩)" : "已取消常驻");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePublished(next: boolean) { async function handleTogglePublished(next: boolean) {
// Publishing: prompt for which square to publish into (when configured). // Publishing: prompt for which square to publish into (when configured).
@@ -806,6 +837,15 @@ export function SkillRow({
{t.settings.skills.badgePinned} {t.settings.skills.badgePinned}
</Badge> </Badge>
)} )}
{skill.always_on && (
<Badge
variant="outline"
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
title="常驻(免压缩):开启技能压缩后,本技能仍全文注入系统提示词并被正常检索"
>
常驻
</Badge>
)}
</div> </div>
); );
@@ -848,6 +888,14 @@ export function SkillRow({
: t.settings.skills.useCountInlineZero} : t.settings.skills.useCountInlineZero}
</div> </div>
)} )}
{formatDateTime(skill.created_at) && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<ClockIcon className="size-3.5 shrink-0" />
<span className="truncate">
创建时间:{formatDateTime(skill.created_at)}
</span>
</div>
)}
{(skill.tags?.length ?? 0) > 0 && ( {(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" /> <TagChipList tags={skill.tags!} className="mt-2" />
)} )}
@@ -907,6 +955,27 @@ export function SkillRow({
/> />
</Button> </Button>
)} )}
{compression?.enabled && canSetAlwaysOn && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.always_on && "text-amber-600 hover:text-amber-700",
)}
disabled={setAlwaysOn.isPending}
onClick={() => void handleToggleAlwaysOn(!skill.always_on)}
title={
skill.always_on
? "取消常驻(将被压缩,改由 search_skills 检索)"
: "设为常驻(免压缩,始终全文注入提示词)"
}
>
<Anchor
className={cn("size-3.5", skill.always_on && "fill-current")}
/>
</Button>
)}
{canTogglePublish && ( {canTogglePublish && (
<Button <Button
size="icon" size="icon"
@@ -26,6 +26,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
@@ -37,12 +38,14 @@ import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
useGenerateSkillNameZh, useGenerateSkillNameZh,
useSetSkillAlwaysOn,
useSetSkillDetail, useSetSkillDetail,
useSetSkillNameZh, useSetSkillNameZh,
useSkill, useSkill,
useSkillContent, useSkillContent,
useSkillFiles, useSkillFiles,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
import { useSkillCompressionSettings } from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
@@ -105,6 +108,8 @@ export function SkillDetailDialog({
const setDetail = useSetSkillDetail(); const setDetail = useSetSkillDetail();
const setNameZh = useSetSkillNameZh(); const setNameZh = useSetSkillNameZh();
const generateNameZh = useGenerateSkillNameZh(); const generateNameZh = useGenerateSkillNameZh();
const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@@ -327,6 +332,15 @@ export function SkillDetailDialog({
{tx.badgePublished} {tx.badgePublished}
</Badge> </Badge>
)} )}
{skill?.always_on && compression?.enabled && (
<Badge
variant="outline"
className="border-amber-500/30 text-xs text-amber-700 dark:text-amber-300"
title="常驻(免压缩):本技能始终全文注入系统提示词"
>
常驻
</Badge>
)}
</div> </div>
)} )}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed"> <DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
@@ -372,6 +386,31 @@ export function SkillDetailDialog({
</TabsList> </TabsList>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{canManage && compression?.enabled && skill && (
<div
className="mr-1 flex items-center gap-1.5"
title="开启后,即使启用了技能压缩,本技能仍会全文注入系统提示词并被正常检索(免压缩)"
>
<span className="text-muted-foreground text-xs">常驻(免压缩)</span>
<Switch
checked={!!skill.always_on}
disabled={setAlwaysOn.isPending}
onCheckedChange={(next) =>
setAlwaysOn.mutate(
{ skillName: skill.name, alwaysOn: next },
{
onSuccess: () =>
toast.success(next ? "已设为常驻(免压缩)" : "已取消常驻"),
onError: (e) =>
toast.error(
e instanceof Error ? e.message : "更新常驻状态失败",
),
},
)
}
/>
</div>
)}
{selectedTab === "detail" && canManage && !editing && ( {selectedTab === "detail" && canManage && !editing && (
<Button <Button
size="sm" size="sm"
@@ -0,0 +1,98 @@
"use client";
import { CheckIcon, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { resolveTagColor } from "@/components/workspace/tags/tag-colors";
import { useResourceTags, useSetResourceTags, useTags, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
/** Generic admin dialog to assign a resource's tags (multi-select chips). */
export function ResourceTagDialog({
open,
onOpenChange,
targetType,
targetId,
title = "打标签",
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
targetType: TagType;
targetId: string;
title?: string;
}) {
const { data: tags } = useTags(targetType);
const { data: current } = useResourceTags(targetType, targetId, open);
const setTags = useSetResourceTags();
const [selected, setSelected] = useState<string[]>([]);
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${targetId}:${current ? "ready" : "loading"}`;
if (open && current && syncedFor !== syncKey) {
setSelected(current.map((t) => t.id));
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
const toggle = (id: string) =>
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
const handleSave = async () => {
try {
await setTags.mutateAsync({ targetType, targetId, tagIds: selected });
onOpenChange(false);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{!tags || tags.length === 0 ? (
<p className="text-muted-foreground text-sm">暂无可用标签,请先到「标签管理」创建对应类型的标签。</p>
) : (
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => {
const checked = selected.includes(tag.id);
const color = resolveTagColor(tag.color);
return (
<button
key={tag.id}
type="button"
onClick={() => toggle(tag.id)}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all",
color.chip,
checked ? "ring-foreground/40 ring-2 ring-offset-1" : "opacity-45 hover:opacity-90",
)}
>
{checked && <CheckIcon className="size-3" />}
{tag.name}
</button>
);
})}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={setTags.isPending}>
{setTags.isPending && <Loader2 className="size-4 animate-spin" />}保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -4,11 +4,13 @@ import {
BellIcon, BellIcon,
BotIcon, BotIcon,
BrainIcon, BrainIcon,
BriefcaseIcon,
CalendarClockIcon, CalendarClockIcon,
ChevronsUpDown, ChevronsUpDown,
DatabaseZapIcon, DatabaseZapIcon,
GaugeIcon, GaugeIcon,
HistoryIcon, HistoryIcon,
MessageCircleQuestionIcon,
ScrollTextIcon, ScrollTextIcon,
Settings2Icon, Settings2Icon,
SettingsIcon, SettingsIcon,
@@ -216,6 +218,26 @@ export function WorkspaceNavMenu() {
<TagsIcon /> <TagsIcon />
{t.adminTags.title} {t.adminTags.title}
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/positions")}
>
<BriefcaseIcon />
岗位管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate(
"/page/workspace/settings/recommended-questions",
)
}
>
<MessageCircleQuestionIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && ( {isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
@@ -12,7 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { FrequentAgentsList } from "./frequent-agents-list"; import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list"; import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list"; import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list"; import { RecentWritingList } from "./recent-writing-list";
@@ -30,7 +30,8 @@ export function WorkspaceSidebar({
const pathname = usePathname(); const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light"; const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。 // 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;其余 workspace 页保持原样。 // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats"); const isGeneralQa = pathname.startsWith("/page/workspace/chats");
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。 // 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
@@ -58,13 +59,16 @@ export function WorkspaceSidebar({
{...props} {...props}
> >
<SidebarContent> <SidebarContent>
<WorkspaceNavChatList showCollapseTrigger compact /> {isGeneralQa ? (
{isGeneralQa && <FrequentAgentsList />} // 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
<RecentChatList // GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
compact <GeneralQaSidebar />
limit={isGeneralQa ? 10 : undefined} ) : (
moreHref={isGeneralQa ? "/page/workspace/chats" : undefined} <>
/> <WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent> </SidebarContent>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
+175
View File
@@ -0,0 +1,175 @@
/**
* 外部系统跳转登录时的「任务深链」:登录 URL 带 ``taskId`` + ``goPath`` 时,
* 拉取任务详情并据 goPath 把用户直接送到对应的智能体问答 / 多智能体会商页。
*
* 流程:
* 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/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}/taskAnalyseSearch/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` };
}
+111 -1
View File
@@ -4,8 +4,11 @@ import { getBackendBaseURL } from "@/core/config";
import type { import type {
BatchIngestPayload, BatchIngestPayload,
BatchIngestResult, BatchIngestResult,
ExtractTemplateScope,
GraphExportResult, GraphExportResult,
IngestThreadPayload, IngestThreadPayload,
KnowledgeExtractTemplate,
KnowledgeFolder,
KnowledgeListParams, KnowledgeListParams,
KnowledgeListResponse, KnowledgeListResponse,
KnowledgeNote, KnowledgeNote,
@@ -35,6 +38,7 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
if (params.entity) qs.set("entity", params.entity); if (params.entity) qs.set("entity", params.entity);
if (params.source_type) qs.set("source_type", params.source_type); if (params.source_type) qs.set("source_type", params.source_type);
if (params.status) qs.set("status", params.status); if (params.status) qs.set("status", params.status);
if (params.folder !== undefined) qs.set("folder", params.folder);
qs.set("limit", String(params.limit ?? 20)); qs.set("limit", String(params.limit ?? 20));
qs.set("offset", String(params.offset ?? 0)); qs.set("offset", String(params.offset ?? 0));
return parseResponse<KnowledgeListResponse>( return parseResponse<KnowledgeListResponse>(
@@ -54,6 +58,7 @@ export async function createKnowledgeNote(data: {
summary?: string | null; summary?: string | null;
tags?: string[]; tags?: string[];
status?: string; status?: string;
folder?: string | null;
}) { }) {
const res = await parseResponse<{ note: KnowledgeNote }>( const res = await parseResponse<{ note: KnowledgeNote }>(
await apiFetch(`${baseUrl()}/notes`, { await apiFetch(`${baseUrl()}/notes`, {
@@ -73,6 +78,7 @@ export async function updateKnowledgeNote(
summary: string | null; summary: string | null;
tags: string[]; tags: string[];
status: string; status: string;
folder: string | null;
}>, }>,
) { ) {
return parseResponse<KnowledgeNote>( return parseResponse<KnowledgeNote>(
@@ -138,7 +144,14 @@ export interface IngestFileResult {
/** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */ /** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */
export async function ingestKnowledgeFile( export async function ingestKnowledgeFile(
file: File, file: File,
opts: { title?: string; tags?: string; status?: string; model_name?: string | null } = {}, opts: {
title?: string;
tags?: string;
status?: string;
model_name?: string | null;
template_id?: string | null;
folder?: string | null;
} = {},
) { ) {
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
@@ -146,6 +159,8 @@ export async function ingestKnowledgeFile(
if (opts.tags) form.append("tags", opts.tags); if (opts.tags) form.append("tags", opts.tags);
if (opts.status) form.append("status", opts.status); if (opts.status) form.append("status", opts.status);
if (opts.model_name) form.append("model_name", opts.model_name); if (opts.model_name) form.append("model_name", opts.model_name);
if (opts.template_id) form.append("template_id", opts.template_id);
if (opts.folder) form.append("folder", opts.folder);
// No Content-Type header — the browser sets the multipart boundary itself. // No Content-Type header — the browser sets the multipart boundary itself.
return parseResponse<IngestFileResult>( return parseResponse<IngestFileResult>(
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }), await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
@@ -284,3 +299,98 @@ export async function removeGraphBlacklist(entryId: string) {
}), }),
); );
} }
// --- folders (user-managed directories) ---
export async function listKnowledgeFolders() {
const res = await parseResponse<{ items: KnowledgeFolder[] }>(
await apiFetch(`${baseUrl()}/folders`),
);
return res.items;
}
export async function createKnowledgeFolder(path: string) {
const res = await parseResponse<{ folder: KnowledgeFolder }>(
await apiFetch(`${baseUrl()}/folders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
}),
);
return res.folder;
}
export async function renameKnowledgeFolder(folderId: string, path: string) {
const res = await parseResponse<{ folder: KnowledgeFolder }>(
await apiFetch(`${baseUrl()}/folders/${encodeURIComponent(folderId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
}),
);
return res.folder;
}
export async function deleteKnowledgeFolder(folderId: string, reassignTo?: string) {
const qs = reassignTo ? `?reassign_to=${encodeURIComponent(reassignTo)}` : "";
return parseResponse<{ ok: boolean }>(
await apiFetch(`${baseUrl()}/folders/${encodeURIComponent(folderId)}${qs}`, {
method: "DELETE",
}),
);
}
// --- extraction prompt templates ---
export async function listExtractTemplates(scope?: ExtractTemplateScope, enabledOnly = false) {
const qs = new URLSearchParams();
if (scope) qs.set("scope", scope);
if (enabledOnly) qs.set("enabled_only", "true");
const q = qs.toString();
const res = await parseResponse<{ items: KnowledgeExtractTemplate[] }>(
await apiFetch(`${baseUrl()}/extract-templates${q ? `?${q}` : ""}`),
);
return res.items;
}
export type ExtractTemplateInput = {
name: string;
system_prompt: string;
scope?: ExtractTemplateScope;
description?: string | null;
is_default?: boolean;
enabled?: boolean;
};
export async function createExtractTemplate(data: ExtractTemplateInput) {
const res = await parseResponse<{ template: KnowledgeExtractTemplate }>(
await apiFetch(`${baseUrl()}/extract-templates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
return res.template;
}
export async function updateExtractTemplate(
templateId: string,
data: Partial<ExtractTemplateInput>,
) {
const res = await parseResponse<{ template: KnowledgeExtractTemplate }>(
await apiFetch(`${baseUrl()}/extract-templates/${encodeURIComponent(templateId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
return res.template;
}
export async function deleteExtractTemplate(templateId: string) {
return parseResponse<{ ok: boolean }>(
await apiFetch(`${baseUrl()}/extract-templates/${encodeURIComponent(templateId)}`, {
method: "DELETE",
}),
);
}
+68 -1
View File
@@ -7,7 +7,11 @@ import {
import { import {
addGraphBlacklist, addGraphBlacklist,
createExtractTemplate,
createKnowledgeFolder,
createKnowledgeNote, createKnowledgeNote,
deleteExtractTemplate,
deleteKnowledgeFolder,
deleteKnowledgeNote, deleteKnowledgeNote,
exportKnowledgeGraph, exportKnowledgeGraph,
fetchVaultPage, fetchVaultPage,
@@ -18,14 +22,18 @@ import {
ingestThread, ingestThread,
ingestThreadsBatch, ingestThreadsBatch,
listDuplicates, listDuplicates,
listExtractTemplates,
listKnowledgeFolders,
listKnowledgeNotes, listKnowledgeNotes,
mergeKnowledgeNotes, mergeKnowledgeNotes,
removeGraphBlacklist, removeGraphBlacklist,
renameKnowledgeFolder,
resolveNoteWikilinks, resolveNoteWikilinks,
searchKnowledge, searchKnowledge,
updateExtractTemplate,
updateKnowledgeNote, updateKnowledgeNote,
} from "./api"; } from "./api";
import type { KnowledgeListParams } from "./types"; import type { ExtractTemplateScope, KnowledgeListParams } from "./types";
const ROOT_KEY = ["knowledge"] as const; const ROOT_KEY = ["knowledge"] as const;
@@ -172,3 +180,62 @@ export function useKnowledgeDuplicates(enabled: boolean) {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
} }
// --- folders (user-managed directories) ---
export function useKnowledgeFolders() {
return useQuery({
queryKey: [...ROOT_KEY, "folders"],
queryFn: listKnowledgeFolders,
refetchOnWindowFocus: false,
});
}
export function useFolderActions() {
const queryClient = useQueryClient();
// Folder changes can move notes around → invalidate the whole KB tree.
const invalidate = () => queryClient.invalidateQueries({ queryKey: ROOT_KEY });
return {
create: useMutation({ mutationFn: createKnowledgeFolder, onSuccess: invalidate }),
rename: useMutation({
mutationFn: ({ folderId, path }: { folderId: string; path: string }) =>
renameKnowledgeFolder(folderId, path),
onSuccess: invalidate,
}),
remove: useMutation({
mutationFn: ({ folderId, reassignTo }: { folderId: string; reassignTo?: string }) =>
deleteKnowledgeFolder(folderId, reassignTo),
onSuccess: invalidate,
}),
};
}
// --- extraction prompt templates ---
export function useExtractTemplates(scope?: ExtractTemplateScope, enabledOnly = false) {
return useQuery({
queryKey: [...ROOT_KEY, "extract-templates", scope ?? "all", enabledOnly],
queryFn: () => listExtractTemplates(scope, enabledOnly),
refetchOnWindowFocus: false,
});
}
export function useExtractTemplateActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [...ROOT_KEY, "extract-templates"] });
return {
create: useMutation({ mutationFn: createExtractTemplate, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({
templateId,
data,
}: {
templateId: string;
data: Parameters<typeof updateExtractTemplate>[1];
}) => updateExtractTemplate(templateId, data),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteExtractTemplate, onSuccess: invalidate }),
};
}
+46
View File
@@ -31,6 +31,8 @@ export interface KnowledgeNote {
summary?: string | null; summary?: string | null;
content_md: string; content_md: string;
vault_path?: string | null; vault_path?: string | null;
/** User-assigned directory (slash-joined path); null/undefined = unfiled. */
folder?: string | null;
source_type: string; source_type: string;
source_id?: string | null; source_id?: string | null;
status: KnowledgeStatus; status: KnowledgeStatus;
@@ -58,10 +60,37 @@ export interface KnowledgeListParams {
entity?: string; entity?: string;
source_type?: string; source_type?: string;
status?: string; status?: string;
/** Filter to a directory (and its sub-folders); "" = unfiled only. */
folder?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
} }
/** A user-managed knowledge directory. */
export interface KnowledgeFolder {
id: string;
path: string;
name: string;
sort_order?: number;
}
export type ExtractTemplateScope = "thread" | "document" | "both";
/** An editable knowledge-extraction prompt template. */
export interface KnowledgeExtractTemplate {
id: string;
name: string;
description?: string | null;
scope: ExtractTemplateScope;
system_prompt: string;
is_default: boolean;
enabled: boolean;
created_by?: string | null;
updated_by?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export type WikilinkKind = "note" | "vault" | "missing"; export type WikilinkKind = "note" | "vault" | "missing";
export interface WikilinkResolution { export interface WikilinkResolution {
@@ -105,6 +134,10 @@ export interface IngestThreadPayload {
background?: boolean; background?: boolean;
/** Optional model override for knowledge extraction (null = configured default). */ /** Optional model override for knowledge extraction (null = configured default). */
model_name?: string | null; model_name?: string | null;
/** Optional extraction-prompt template id (null = scope default). */
template_id?: string | null;
/** Optional directory to file the result under (slash-joined path). */
folder?: string | null;
} }
export interface BatchIngestPayload { export interface BatchIngestPayload {
@@ -114,12 +147,25 @@ export interface BatchIngestPayload {
include_sources?: boolean; include_sources?: boolean;
skip_existing?: boolean; skip_existing?: boolean;
mode?: "summary" | "full"; mode?: "summary" | "full";
/**
* When true (default), the backend runs the per-thread extraction loop in a
* background task and returns immediately (`queued:true`) instead of blocking
* the request until every thread is processed — which otherwise outlives the
* nginx gateway timeout and surfaces as a generic "request failed".
*/
background?: boolean;
/** Optional model override for knowledge extraction (null = configured default). */ /** Optional model override for knowledge extraction (null = configured default). */
model_name?: string | null; model_name?: string | null;
/** Optional extraction-prompt template id (null = scope default). */
template_id?: string | null;
/** Optional directory to file results under (slash-joined path). */
folder?: string | null;
} }
export interface BatchIngestResult { export interface BatchIngestResult {
ok: boolean; ok: boolean;
/** True when the backend queued the batch as a background task (see payload). */
queued?: boolean;
processed: number; processed: number;
created: number; created: number;
skipped: number; skipped: number;
@@ -13,6 +13,7 @@ import {
BookOpen, BookOpen,
Bot, Bot,
Brain, Brain,
Briefcase,
CalendarClock, CalendarClock,
CircleHelp, CircleHelp,
ClipboardList, ClipboardList,
@@ -52,7 +53,7 @@ export type MenuItem = {
export const sidebarMenuItems: MenuItem[] = [ export const sidebarMenuItems: MenuItem[] = [
{ {
id: "qa-center", id: "qa-center",
label: "问答中心", label: "问答管理",
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, { id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
@@ -65,11 +66,11 @@ export const sidebarMenuItems: MenuItem[] = [
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" }, // { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{ {
id: "qa-zzjc", id: "qa-zzjc",
label: "zzJC支持", label: "JC支持",
icon: Bot, 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" }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
], ],
}, },
@@ -102,24 +103,24 @@ export const sidebarMenuItems: MenuItem[] = [
label: "COH中任务", label: "COH中任务",
icon: Activity, icon: Activity,
children: [ children: [
{ // {
id: "coh-task-3qfx", // id: "coh-task-3qfx",
label: "3QFX", // label: "3QFX",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/plan-task-sentiment", // path: "/page/strategy/task-management/plan-task-sentiment",
}, // },
{ // {
id: "coh-task-fx", // id: "coh-task-fx",
label: "任务FX", // label: "任务FX",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx", // path: "/page/strategy/task-management/plan-task/plan-task-fx",
}, // },
{ // {
id: "coh-task-xdfx", // id: "coh-task-xdfx",
label: "XDFX", // label: "XDFX",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-action", // path: "/page/strategy/task-management/plan-task/plan-task-action",
}, // },
], ],
}, },
{ {
@@ -241,6 +242,13 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/users", path: "/page/strategy/admin/users",
adminOnly: true, adminOnly: true,
}, },
{
id: "mgmt-positions",
label: "岗位管理",
icon: Briefcase,
path: "/page/strategy/admin/positions",
adminOnly: true,
},
{ {
id: "mgmt-admin-scheduled-tasks", id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理", label: "定时任务管理",
+130
View File
@@ -0,0 +1,130 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
Position,
PositionCreateInput,
PositionListResponse,
PositionMember,
PositionUpdateInput,
PositionUser,
TagBindings,
TagBindingsResponse,
} from "./types";
async function parseDetail(res: Response, fallback: string): Promise<Error> {
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
}
const base = () => `${getBackendBaseURL()}/api/positions`;
export async function listPositions(): Promise<Position[]> {
const res = await apiFetch(base());
if (!res.ok) throw await parseDetail(res, "Failed to list positions");
const data = (await res.json()) as PositionListResponse;
return data.positions;
}
export async function createPosition(input: PositionCreateInput): Promise<Position> {
const res = await apiFetch(base(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw await parseDetail(res, "Failed to create position");
return (await res.json()) as Position;
}
export async function updatePosition(id: string, input: PositionUpdateInput): Promise<Position> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw await parseDetail(res, "Failed to update position");
return (await res.json()) as Position;
}
export async function deletePosition(id: string): Promise<void> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok) throw await parseDetail(res, "Failed to delete position");
}
export async function getTagBindings(positionId: string): Promise<TagBindings> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/tag-bindings`);
if (!res.ok) throw await parseDetail(res, "Failed to load tag bindings");
const data = (await res.json()) as TagBindingsResponse;
return data.bindings;
}
export async function setTagBindings(positionId: string, bindings: TagBindings): Promise<TagBindings> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/tag-bindings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bindings }),
});
if (!res.ok) throw await parseDetail(res, "Failed to save tag bindings");
const data = (await res.json()) as TagBindingsResponse;
return data.bindings;
}
export async function listMembers(positionId: string): Promise<PositionMember[]> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`);
if (!res.ok) throw await parseDetail(res, "Failed to list members");
const data = (await res.json()) as { members: PositionMember[] };
return data.members;
}
export async function addMembers(positionId: string, userIds: string[]): Promise<PositionMember[]> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_ids: userIds }),
});
if (!res.ok) throw await parseDetail(res, "Failed to add members");
const data = (await res.json()) as { members: PositionMember[] };
return data.members;
}
export async function removeMembers(positionId: string, userIds: string[]): Promise<PositionMember[]> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_ids: userIds }),
});
if (!res.ok) throw await parseDetail(res, "Failed to remove members");
const data = (await res.json()) as { members: PositionMember[] };
return data.members;
}
export async function resyncPosition(positionId: string): Promise<number> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/resync`, { method: "POST" });
if (!res.ok) throw await parseDetail(res, "Failed to resync position");
const data = (await res.json()) as { synced: number };
return data.synced;
}
export async function getMyPosition(): Promise<Position | null> {
const res = await apiFetch(`${base()}/me`);
if (!res.ok) throw await parseDetail(res, "Failed to load my position");
const data = (await res.json()) as { position: Position | null };
return data.position ?? null;
}
export async function listPositionUsers(): Promise<PositionUser[]> {
const res = await apiFetch(`${base()}/users`);
if (!res.ok) throw await parseDetail(res, "Failed to list users");
const data = (await res.json()) as { users: PositionUser[] };
return data.users;
}
export async function setUserPosition(userId: string, positionId: string | null): Promise<void> {
const res = await apiFetch(`${base()}/users/${encodeURIComponent(userId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ position_id: positionId }),
});
if (!res.ok) throw await parseDetail(res, "Failed to set user position");
}
+131
View File
@@ -0,0 +1,131 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
addMembers,
createPosition,
deletePosition,
getMyPosition,
getTagBindings,
listMembers,
listPositionUsers,
listPositions,
removeMembers,
resyncPosition,
setTagBindings,
setUserPosition,
updatePosition,
} from "./api";
import type {
PositionCreateInput,
PositionUpdateInput,
TagBindings,
} from "./types";
const positionKeys = {
root: ["positions"] as const,
list: ["positions", "list"] as const,
bindings: (id: string) => ["positions", "bindings", id] as const,
members: (id: string) => ["positions", "members", id] as const,
users: ["positions", "users"] as const,
me: ["positions", "me"] as const,
};
export function usePositions() {
return useQuery({ queryKey: positionKeys.list, queryFn: listPositions });
}
/** The caller's own effective position (explicit, else default). */
export function useMyPosition() {
return useQuery({ queryKey: positionKeys.me, queryFn: getMyPosition });
}
export function usePositionUsers() {
return useQuery({ queryKey: positionKeys.users, queryFn: listPositionUsers });
}
export function usePositionTagBindings(positionId: string | null) {
return useQuery({
queryKey: positionKeys.bindings(positionId ?? ""),
queryFn: () => getTagBindings(positionId as string),
enabled: !!positionId,
});
}
export function usePositionMembers(positionId: string | null) {
return useQuery({
queryKey: positionKeys.members(positionId ?? ""),
queryFn: () => listMembers(positionId as string),
enabled: !!positionId,
});
}
function useInvalidate() {
const qc = useQueryClient();
return () => void qc.invalidateQueries({ queryKey: positionKeys.root });
}
export function useCreatePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (input: PositionCreateInput) => createPosition(input),
onSuccess: invalidate,
});
}
export function useUpdatePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: PositionUpdateInput }) => updatePosition(id, input),
onSuccess: invalidate,
});
}
export function useDeletePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => deletePosition(id),
onSuccess: invalidate,
});
}
export function useSetTagBindings() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ positionId, bindings }: { positionId: string; bindings: TagBindings }) =>
setTagBindings(positionId, bindings),
onSuccess: (_data, { positionId }) => {
void qc.invalidateQueries({ queryKey: positionKeys.bindings(positionId) });
},
});
}
export function useAddMembers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ positionId, userIds }: { positionId: string; userIds: string[] }) =>
addMembers(positionId, userIds),
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
});
}
export function useRemoveMembers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ positionId, userIds }: { positionId: string; userIds: string[] }) =>
removeMembers(positionId, userIds),
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
});
}
export function useResyncPosition() {
return useMutation({ mutationFn: (positionId: string) => resyncPosition(positionId) });
}
export function useSetUserPosition() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, positionId }: { userId: string; positionId: string | null }) =>
setUserPosition(userId, positionId),
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./types";
export * from "./api";
export * from "./hooks";
+82
View File
@@ -0,0 +1,82 @@
import type { TagType } from "@/core/tags/types";
/** Resource types a 岗位 (position) can grant, by tag. */
export type PositionResourceType =
| "agent"
| "skill"
| "scheduled_task"
| "recommended_question";
export const POSITION_RESOURCE_TYPES: PositionResourceType[] = [
"agent",
"skill",
"scheduled_task",
"recommended_question",
];
// A position's resource types are exactly the tag types — reuse the labels.
export const POSITION_RESOURCE_LABELS: Record<PositionResourceType, string> = {
agent: "智能体",
skill: "技能",
scheduled_task: "定时任务",
recommended_question: "推荐问题",
};
export const POSITION_RESOURCE_HINT: Record<PositionResourceType, string> = {
agent: "不配置标签 = 默认加载全部内置智能体;配置后只加载所选标签下的智能体。",
skill: "不配置标签 = 不向「我的技能」添加任何技能;配置后将所选标签下的技能加入成员的「我的技能」(技能广场仍展示全部技能)。",
scheduled_task: "不配置标签 = 不订阅任何定时任务;配置后订阅所选标签下的定时任务。",
recommended_question: "不配置标签 = 展示全部推荐问题;配置后只展示所选标签下的推荐问题(聊天框上方的推荐问题)。",
};
export interface Position {
id: string;
name: string;
description: string;
is_default: boolean;
member_count: number;
created_at: string | null;
updated_at: string | null;
}
export interface PositionListResponse {
positions: Position[];
resource_types: PositionResourceType[];
}
export interface PositionCreateInput {
name: string;
description?: string;
is_default?: boolean;
}
export interface PositionUpdateInput {
name?: string;
description?: string;
is_default?: boolean;
}
/** resource_type -> [tag_id] */
export type TagBindings = Partial<Record<PositionResourceType, string[]>>;
export interface TagBindingsResponse {
position_id: string;
bindings: TagBindings;
}
export interface PositionMember {
id: string;
email: string;
}
export interface PositionUser {
id: string;
email: string;
system_role: string;
position_id: string | null;
}
// The resource types double as tag types for the binding pickers.
export function resourceTypeToTagType(rt: PositionResourceType): TagType {
return rt as TagType;
}
@@ -9,8 +9,10 @@ import type {
const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`; const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`;
export async function listRecommendedQuestions(): Promise<RecommendedQuestion[]> { export async function listRecommendedQuestions(all = false): Promise<RecommendedQuestion[]> {
const res = await apiFetch(BASE()); // ``all=true`` is the admin management view: every question, unfiltered by the
// caller's 岗位 (position). The chat input uses the default (filtered) view.
const res = await apiFetch(`${BASE()}${all ? "?all=1" : ""}`);
if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`); if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`);
const data = (await res.json()) as { questions: RecommendedQuestion[] }; const data = (await res.json()) as { questions: RecommendedQuestion[] };
return data.questions; return data.questions;
@@ -10,11 +10,21 @@ import {
import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index"; import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index";
const QK = ["recommended-questions"] as const; const QK = ["recommended-questions"] as const;
const ADMIN_QK = ["recommended-questions", "admin"] as const;
export function useRecommendedQuestions() { export function useRecommendedQuestions() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: QK, queryKey: QK,
queryFn: listRecommendedQuestions, queryFn: () => listRecommendedQuestions(false),
});
return { questions: data ?? [], isLoading, error };
}
/** Admin management view — every question, unfiltered by 岗位. */
export function useAdminRecommendedQuestions() {
const { data, isLoading, error } = useQuery({
queryKey: ADMIN_QK,
queryFn: () => listRecommendedQuestions(true),
}); });
return { questions: data ?? [], isLoading, error }; return { questions: data ?? [], isLoading, error };
} }
@@ -1,8 +1,12 @@
import type { Tag } from "@/core/tags";
export interface RecommendedQuestion { export interface RecommendedQuestion {
id: string; id: string;
title: string; title: string;
content: string; content: string;
sort_order: number; sort_order: number;
/** Tags assigned to this question (按岗位分发标签). */
tags?: Tag[];
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
+7
View File
@@ -7,6 +7,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
display: { display: {
streamSpeedMeterEnabled: true, streamSpeedMeterEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
contextCompressionEnabled: false,
citationCleanupWords: [], citationCleanupWords: [],
fontScale: 1, fontScale: 1,
}, },
@@ -43,6 +44,12 @@ export interface LocalSettings {
* messages render tool results with the legacy generic search UI only. * messages render tool results with the legacy generic search UI only.
*/ */
ragModeEnabled: boolean; ragModeEnabled: boolean;
/**
* Per-user switch for context compression (上下文压缩). When enabled, long
* conversations are summarized transiently before the model call to save
* tokens. Default off; toggled from the chat input box (below 参考文献).
*/
contextCompressionEnabled: boolean;
/** /**
* Admin-maintained words or short phrases removed from reference snippets * Admin-maintained words or short phrases removed from reference snippets
* and dialogs, one entry per line in the settings UI. * and dialogs, one entry per line in the settings UI.
+23
View File
@@ -9,6 +9,8 @@ export interface ListSkillsParams {
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc"; sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
/** Keep only skills filed under this publish square (发布广场). */ /** Keep only skills filed under this publish square (发布广场). */
square?: string; square?: string;
/** ``"mine"`` restricts to the skills the caller's 岗位 (position) makes visible. */
scope?: "mine";
} }
export async function loadSkills(params: ListSkillsParams = {}) { export async function loadSkills(params: ListSkillsParams = {}) {
@@ -17,6 +19,7 @@ export async function loadSkills(params: ListSkillsParams = {}) {
for (const id of params.tagIds ?? []) qs.append("tag_ids", id); for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.sort) qs.set("sort", params.sort); if (params.sort) qs.set("sort", params.sort);
if (params.square) qs.set("square", params.square); if (params.square) qs.set("square", params.square);
if (params.scope) qs.set("scope", params.scope);
const query = qs.toString(); const query = qs.toString();
const skills = await apiFetch( const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`, `${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
@@ -62,6 +65,26 @@ export async function enableSkill(skillName: string, enabled: boolean) {
return response.json(); return response.json();
} }
export async function setSkillAlwaysOn(
skillName: string,
alwaysOn: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/always-on`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ always_on: alwaysOn }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill 常驻 flag");
}
return response.json() as Promise<Skill>;
}
export async function setSkillDisplay( export async function setSkillDisplay(
skillName: string, skillName: string,
display: SkillDisplayConfig | null, display: SkillDisplayConfig | null,
+22 -2
View File
@@ -18,6 +18,7 @@ import {
installSkillUpload, installSkillUpload,
reconcileCustomSkills, reconcileCustomSkills,
saveSkillFile, saveSkillFile,
setSkillAlwaysOn,
setSkillDetail, setSkillDetail,
setSkillNameZh, setSkillNameZh,
setSkillDisplay, setSkillDisplay,
@@ -42,9 +43,10 @@ export function useSkills(params: ListSkillsParams = {}) {
const search = params.search ?? ""; const search = params.search ?? "";
const tagIds = params.tagIds ?? []; const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc"; const sort = params.sort ?? "time_desc";
const scope = params.scope;
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort(), sort], queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
queryFn: () => loadSkills({ search, tagIds, sort }), queryFn: () => loadSkills({ search, tagIds, sort, scope }),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
return { skills: data ?? EMPTY_SKILLS, isLoading, error }; return { skills: data ?? EMPTY_SKILLS, isLoading, error };
@@ -170,6 +172,24 @@ export function useEnableSkill() {
}); });
} }
export function useSetSkillAlwaysOn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, alwaysOn }: { skillName: string; alwaysOn: boolean }) =>
setSkillAlwaysOn(skillName, alwaysOn),
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill", skillName], data);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useValidateSkillUpload() { export function useValidateSkillUpload() {
return useMutation({ return useMutation({
mutationFn: (file: File) => validateSkillUpload(file), mutationFn: (file: File) => validateSkillUpload(file),
+6
View File
@@ -48,6 +48,9 @@ export interface Skill {
category: string; category: string;
license: string; license: string;
enabled: boolean; enabled: boolean;
// 常驻(免压缩) —— 开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,
// 而非被压缩成索引 + search_skills 检索。
always_on?: boolean;
// Multitenancy fields — owner_user_id is null for built-in / public skills. // Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side. // ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null; owner_user_id?: string | null;
@@ -58,6 +61,9 @@ export interface Skill {
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */ /** Publish square (发布广场) the skill is filed under once published. "" = default square. */
square_id?: string; square_id?: string;
builtin?: boolean; builtin?: boolean;
// True when this skill is in the caller's 我的技能 collection — e.g. granted by
// their 岗位 (position). Owned skills are detected separately via owner_user_id.
favorited?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin. // Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown. // Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
detail?: string | null; detail?: string | null;
@@ -247,6 +247,89 @@ export async function updateCitationDisplaySettings(
return (await response.json()) as CitationDisplaySettings; return (await response.json()) as CitationDisplaySettings;
} }
export interface SkillCompressionSettings {
enabled: boolean;
search_top_n: number;
keep_index_in_prompt: boolean;
}
export interface SkillCompressionUpdate {
enabled?: boolean;
search_top_n?: number;
keep_index_in_prompt?: boolean;
}
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
);
if (!response.ok) {
throw new Error(
`Failed to load skill compression settings: ${response.status}`,
);
}
return (await response.json()) as SkillCompressionSettings;
}
export async function updateSkillCompressionSettings(
body: SkillCompressionUpdate,
): Promise<SkillCompressionSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update skill compression" }));
throw new Error(detail.detail ?? "Failed to update skill compression");
}
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 { export interface LogSystemSettings {
enabled: boolean; enabled: boolean;
url: string; url: string;
@@ -3,24 +3,29 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { import {
loadAgentModuleConfig,
loadAppearanceSettings, loadAppearanceSettings,
loadCitationDisplaySettings, loadCitationDisplaySettings,
loadLeaderboardSettings, loadLeaderboardSettings,
loadPrivateSquareStatus, loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
loadPublishSquares, loadPublishSquares,
loadSkillCompressionSettings,
updateAgentModuleConfig,
updateAppearanceSettings, updateAppearanceSettings,
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateLeaderboardSettings, updateLeaderboardSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
updatePublishSquares, updatePublishSquares,
updateSkillCompressionSettings,
type AppearanceUpdate, type AppearanceUpdate,
type CitationDisplayUpdate, type CitationDisplayUpdate,
type LeaderboardSettingsUpdate, type LeaderboardSettingsUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
type PublishSquaresUpdate, type PublishSquaresUpdate,
type SkillCompressionUpdate,
type LayoutMode, type LayoutMode,
type ViewMode, type ViewMode,
} from "./api"; } from "./api";
@@ -31,6 +36,7 @@ const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const; const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const; const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
export function usePromptPrefixSettings() { export function usePromptPrefixSettings() {
return useQuery({ return useQuery({
@@ -165,6 +171,29 @@ export function useLayoutMode(): [
return [mode, setMode]; 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() { export function useCitationDisplaySettings() {
return useQuery({ return useQuery({
queryKey: CITATION_DISPLAY_KEY, queryKey: CITATION_DISPLAY_KEY,
@@ -186,6 +215,29 @@ export function useUpdateCitationDisplaySettings() {
}); });
} }
/** Skill compression (技能压缩) settings. Readable by any logged-in user — the
* skill detail page reads it to decide whether to show the 常驻(免压缩) toggle. */
export function useSkillCompressionSettings() {
return useQuery({
queryKey: SKILL_COMPRESSION_KEY,
queryFn: () => loadSkillCompressionSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateSkillCompressionSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: SkillCompressionUpdate) =>
updateSkillCompressionSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(SKILL_COMPRESSION_KEY, data);
},
});
}
export function useLeaderboardSettings() { export function useLeaderboardSettings() {
return useQuery({ return useQuery({
queryKey: LEADERBOARD_KEY, queryKey: LEADERBOARD_KEY,
+1
View File
@@ -83,6 +83,7 @@ export function useSetResourceTags() {
void queryClient.invalidateQueries({ queryKey: ["skills"] }); void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["agents"] }); void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] }); void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
void queryClient.invalidateQueries({ queryKey: ["recommended-questions"] });
}, },
}); });
} }
+19 -2
View File
@@ -1,6 +1,23 @@
export type TagType = "agent" | "skill" | "scheduled_task"; export type TagType =
| "agent"
| "skill"
| "scheduled_task"
| "recommended_question";
export const TAG_TYPES: TagType[] = ["agent", "skill", "scheduled_task"]; export const TAG_TYPES: TagType[] = [
"agent",
"skill",
"scheduled_task",
"recommended_question",
];
// Human labels for the tag types (used by the tag management + 岗位 pages).
export const TAG_TYPE_LABELS: Record<TagType, string> = {
agent: "智能体",
skill: "技能",
scheduled_task: "定时任务",
recommended_question: "推荐问题",
};
export interface Tag { export interface Tag {
id: string; id: string;
+21
View File
@@ -343,6 +343,23 @@ export function useThreadStream({
) { ) {
const e = event as { type: "llm_retry"; message: string }; const e = event as { type: "llm_retry"; message: string };
toast(e.message); toast(e.message);
return;
}
// Context compression about to run a (blocking) summarization LLM call —
// surface a transient hint so the wait before the answer streams isn't a
// silent dead air. Emitted by DeerFlowSummarizationMiddleware.
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "context_compacting" &&
"message" in event &&
typeof event.message === "string" &&
event.message.trim()
) {
const e = event as { type: "context_compacting"; message: string };
toast(e.message);
} }
}, },
onError(error) { onError(error) {
@@ -647,6 +664,9 @@ export function useThreadStream({
lockThreadRagMode(threadId); lockThreadRagMode(threadId);
} }
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
const mergedContext = { const mergedContext = {
...context, ...context,
...extraContext, ...extraContext,
@@ -698,6 +718,7 @@ export function useThreadStream({
memory_injection_enabled: memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true, mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled, rag_mode_enabled: ragModeEnabled,
summarization_enabled: contextCompressionEnabled,
thread_id: threadId, thread_id: threadId,
}, },
}, },
+2
View File
@@ -20,6 +20,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
writing_artifact_path?: string; writing_artifact_path?: string;
memory_injection_enabled?: boolean; memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean; rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */ /** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean; knowledge_rag_enabled?: boolean;
agent_id?: string; agent_id?: string;
+15
View File
@@ -25,3 +25,18 @@ export function formatTimeAgo(date: Date | string | number, locale?: Locale) {
locale: getDateFnsLocale(effectiveLocale), locale: getDateFnsLocale(effectiveLocale),
}); });
} }
/**
* Format a created/updated timestamp as `YYYY-MM-DD HH:mm`.
* Returns an empty string for missing or unparseable values.
*/
export function formatDateTime(value?: Date | string | number | null): string {
if (value === null || value === undefined || value === "") return "";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}`
);
}
+34 -13
View File
@@ -100,9 +100,20 @@ function candidateTextFromHref(href: string): string {
/** /**
* Injects a small runtime script into generated HTML so that clicking an * Injects a small runtime script into generated HTML so that clicking an
* in-page anchor (`<a href="#section">`) scrolls within the iframe instead of * in-page anchor (`<a href="#section">`) works inside the preview iframe.
* navigating it. The preview iframe loads from a sandboxed `blob:` URL, where a *
* fragment navigation blanks the frame; intercepting the click avoids that. * The preview loads from a sandboxed `blob:` URL (opaque origin). Such a frame
* is FORBIDDEN from navigating to its own `blob:` URL, so changing the hash
* throws "Not allowed to load local resource: blob:…#page-x". And because CSS
* `:target` only updates on a real fragment navigation, the html-multipage
* skill's `:target`-based sub-page switching can never fire here.
*
* So we never touch the hash. Instead the handler emulates the skill's
* show/hide in JS: a "page" is a `.page` inside a `.pages` container; clicking
* a link to one shows it and hides its siblings (mirroring the
* `.page:target` + `~ #page-home` rules via inline `display`, which outranks
* the skill's class-level CSS). Plain in-page anchors (TOC headings, etc.) just
* smooth-scroll as before.
*/ */
export function injectInPageAnchorHandler(html: string): string { export function injectInPageAnchorHandler(html: string): string {
const script = `<script>(function(){ const script = `<script>(function(){
@@ -112,6 +123,22 @@ export function injectInPageAnchorHandler(html: string): string {
var esc=window.CSS&&CSS.escape?CSS.escape(id):id.replace(/["\\\\]/g,"\\\\$&"); var esc=window.CSS&&CSS.escape?CSS.escape(id):id.replace(/["\\\\]/g,"\\\\$&");
try{return document.querySelector("#"+esc+",[name=\\""+esc+"\\"]");}catch(e){return null;} try{return document.querySelector("#"+esc+",[name=\\""+esc+"\\"]");}catch(e){return null;}
} }
function find(raw){
var id=decode(raw);
return document.getElementById(id)||document.getElementById(raw)||
document.getElementsByName(id)[0]||document.getElementsByName(raw)[0]||
bySelector(id)||bySelector(raw);
}
function switchPage(target){
var container=target.parentElement;
while(container&&!(container.classList&&container.classList.contains("pages"))){container=container.parentElement;}
var pages=(container||document).querySelectorAll(".page");
if(!pages.length)return false;
for(var i=0;i<pages.length;i++){pages[i].style.display="none";}
target.style.display="block";
if(window.scrollTo){window.scrollTo(0,0);}
return true;
}
document.addEventListener("click",function(e){ document.addEventListener("click",function(e){
var el=e.target; var el=e.target;
while(el&&el.tagName!=="A"){el=el.parentElement;} while(el&&el.tagName!=="A"){el=el.parentElement;}
@@ -119,16 +146,10 @@ export function injectInPageAnchorHandler(html: string): string {
var href=el.getAttribute("href"); var href=el.getAttribute("href");
if(!href||href.charAt(0)!=="#"||href.length<2)return; if(!href||href.charAt(0)!=="#"||href.length<2)return;
e.preventDefault(); e.preventDefault();
var id=decode(href.slice(1)); var target=find(href.slice(1));
var raw=href.slice(1); if(!target)return;
var target= if(target.classList&&target.classList.contains("page")&&switchPage(target))return;
document.getElementById(id)|| target.scrollIntoView({behavior:"smooth",block:"start"});
document.getElementById(raw)||
document.getElementsByName(id)[0]||
document.getElementsByName(raw)[0]||
bySelector(id)||
bySelector(raw);
if(target){target.scrollIntoView({behavior:"smooth",block:"start"});}
},true); },true);
})();</script>`; })();</script>`;
if (/<\/body>/i.test(html)) { if (/<\/body>/i.test(html)) {
+23
View File
@@ -4,6 +4,9 @@
* value: 脱敏后替换词 * value: 脱敏后替换词
*/ */
export const DESENSITIZE_MAP: Record<string, string> = { export const DESENSITIZE_MAP: Record<string, string> = {
方案: "FA",
设计: "SJ",
任务: "RW",
态势: "TS", 态势: "TS",
滨海作战体系: "第一张网", 滨海作战体系: "第一张网",
滨海作战部队: "第一张网", 滨海作战部队: "第一张网",
@@ -72,6 +75,26 @@ export function desensitize(text: string): string {
return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched); 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 { PenLine, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -55,8 +55,6 @@ export function AIWritingForm({
onSubmit, onSubmit,
disabled, disabled,
hideAdvancedFields = false, hideAdvancedFields = false,
initialNotebookId,
initialDocumentIds,
studioToken, studioToken,
}: AIWritingFormProps) { }: AIWritingFormProps) {
const { const {
@@ -68,13 +66,12 @@ export function AIWritingForm({
wordCountInput, setWordCountInput, wordCountInput, setWordCountInput,
strictMode, setStrictMode, strictMode, setStrictMode,
materialSource, setMaterialSource, materialSource, setMaterialSource,
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
selectedNotebookId, setSelectedNotebookId,
selectedDocumentIds, setSelectedDocumentIds,
modelName, setModelName, modelName, setModelName,
} = useWritingForm(); } = 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 { models, isLoading: modelsLoading } = useModels();
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken); const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
@@ -146,8 +143,10 @@ export function AIWritingForm({
} }
function toggleDocument(docId: number) { function toggleDocument(docId: number) {
setSelectedDocumentIds((prev) => setSelectedDocumentIds(
prev.includes(docId) ? prev.filter((id) => id !== docId) : [...prev, docId], 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>
);
}
@@ -28,7 +28,7 @@ import { uuid } from '@/core/utils/uuid';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useArticleTypes } from '../../hooks/useArticleTypes'; import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext'; import { useWritingFormOptional } from '../../contexts/WritingFormContext';
import { WritingWelcome } from './WritingWelcome'; import { WritingWelcome } from './WritingWelcome';
/** /**
@@ -42,13 +42,25 @@ import { WritingWelcome } from './WritingWelcome';
* *
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。 * 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
*/ */
export function WritingSetupChat() { export function WritingSetupChat({
onApprove: onApproveProp,
title = '写作助手',
hint = '描述你想写什么,我来帮你填好右侧配置',
}: {
/** 审批卡「应用」回调;缺省回填写作表单(需 WritingFormProvider)。传入则脱离表单上下文,
* 用于复用到「新建研究模板」等场景,把建议字段写进调用方的表单。 */
onApprove?: (payload: SetupWritingApprovalPayload) => void;
title?: string;
hint?: string;
} = {}) {
const threadId = useMemo(() => uuid(), []); const threadId = useMemo(() => uuid(), []);
const [threadPrepared, setThreadPrepared] = useState(false); const [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState(''); const [chatModel, setChatModel] = useState('');
const { models, isLoading: modelsLoading } = useModels(); const { models, isLoading: modelsLoading } = useModels();
const { articleTypes } = useArticleTypes(); const { articleTypes } = useArticleTypes();
const { applyProposal } = useWritingForm(); // 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
const formCtx = useWritingFormOptional();
const applyProposal = onApproveProp ?? formCtx?.applyProposal;
// 首个模型可用时默认选中 // 首个模型可用时默认选中
useEffect(() => { useEffect(() => {
@@ -98,8 +110,8 @@ export function WritingSetupChat() {
const handleApprove = useCallback( const handleApprove = useCallback(
async (payload: SetupWritingApprovalPayload) => { async (payload: SetupWritingApprovalPayload) => {
applyProposal(payload); applyProposal?.(payload);
toast.success('已应用到左侧表单,可继续编辑或开始写作'); toast.success('已应用到表单,可继续编辑');
}, },
[applyProposal], [applyProposal],
); );
@@ -119,8 +131,8 @@ export function WritingSetupChat() {
{!showWelcome && ( {!showWelcome && (
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3"> <header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" /> <Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">写作助手</h2> <h2 className="text-sm font-semibold">{title}</h2>
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好右侧配置</span> <span className="text-muted-foreground text-xs">{hint}</span>
</header> </header>
)} )}
@@ -39,7 +39,7 @@ export function WritingWelcome() {
<div className="flex flex-1 flex-col justify-center gap-6"> <div className="flex flex-1 flex-col justify-center gap-6">
<div> <div>
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white"> <h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
欢迎使用「妙笔 AI」智力打字协作工坊 欢迎使用「ai写作」智力打字协作工坊
</h2> </h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60"> <p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
这是一个专业的自动化写作系统。你只需在下方对话框中 这是一个专业的自动化写作系统。你只需在下方对话框中
@@ -36,6 +36,12 @@ export interface WritingFormValue {
setStrictMode: (v: boolean) => void; setStrictMode: (v: boolean) => void;
materialSource: 'general' | 'notebook'; materialSource: 'general' | 'notebook';
setMaterialSource: (v: 'general' | 'notebook') => void; 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; modelName: string;
setModelName: (v: string) => void; setModelName: (v: string) => void;
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */ /** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
@@ -56,7 +62,17 @@ export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext); 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 [intent, setIntent] = useState('');
const [formExpanded, setFormExpanded] = useState(false); const [formExpanded, setFormExpanded] = useState(false);
const [showOutline, setShowOutline] = useState(true); const [showOutline, setShowOutline] = useState(true);
@@ -66,6 +82,8 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
const [audience, setAudience] = useState('普通读者'); const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false); const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general'); 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 [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => { const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
@@ -100,10 +118,12 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
audience, setAudience, audience, setAudience,
strictMode, setStrictMode, strictMode, setStrictMode,
materialSource, setMaterialSource, materialSource, setMaterialSource,
selectedNotebookId, setSelectedNotebookId,
selectedDocumentIds, setSelectedDocumentIds,
modelName, setModelName, modelName, setModelName,
applyProposal, 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>; 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}' * -d '{"retention_days": 0}'
* ────────────────────────────────────────────────────────────────────────── * ──────────────────────────────────────────────────────────────────────────
*/ */
import { useRef } from 'react'; import { useRef, useState } from 'react';
import { PanelRightOpen } from 'lucide-react'; import { ArrowLeft, PanelRightOpen } from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { GroupImperativeHandle } from 'react-resizable-panels'; import type { GroupImperativeHandle } from 'react-resizable-panels';
@@ -93,6 +93,13 @@ import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel'; import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel'; import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat'; 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 { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext'; import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext'; 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({ export default function AIWritingPage({
hideAdvancedFields = false, hideAdvancedFields = false,
initialNotebookId, initialNotebookId,
@@ -227,12 +309,19 @@ export default function AIWritingPage({
<CanvasUIProvider> <CanvasUIProvider>
<AIWritingHistoryProvider> <AIWritingHistoryProvider>
<AIWritingProvider> <AIWritingProvider>
<WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}> <WritingFormProvider
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
>
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg"> <div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<div className="flex-1 min-h-0 overflow-hidden"> <AIWritingBody
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} /> hideAdvancedFields={hideAdvancedFields}
</div> initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
/>
</div> </div>
</TooltipProvider> </TooltipProvider>
</WritingFormProvider> </WritingFormProvider>
@@ -0,0 +1,300 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { SetupWritingApprovalPayload } from '@/components/workspace/messages';
import { useResearchTemplates } from '../hooks/useResearchTemplates';
import { useArticleTypes } from '../hooks/useArticleTypes';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import type {
CreateResearchTemplateRequest,
ResearchTemplate,
} from '../api/ai-writing-research-templates';
const LIST_PATH = '/page/canvas/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 ResearchTemplateEditorPage() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const editing = !!id;
const { templates, refresh, create, update } = useResearchTemplates();
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
const [form, setForm] = useState<FormState>(EMPTY_FORM);
// 新建即就绪;编辑需等列表加载后回填一次。
const [initialized, setInitialized] = useState(!editing);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
void refresh();
void refreshTypes();
}, [refresh, refreshTypes]);
useEffect(() => {
if (!editing || initialized) return;
const t = templates.find((x) => x.id === id);
if (t) {
setForm(toForm(t));
setInitialized(true);
}
}, [editing, initialized, templates, id]);
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
// 问答助手「应用」→ 把 setup_writing 建议映射进模板表单字段(与 AI 写作同款机制)。
const applyProposalToForm = (p: SetupWritingApprovalPayload) =>
setForm((f) => ({
...f,
name: f.name || (p.user_intent?.trim() ? p.user_intent.trim().slice(0, 20) : f.name),
topic: p.user_intent?.trim() || f.topic,
article_type_key: p.article_type?.trim() || f.article_type_key,
outline: p.outline?.trim() || f.outline,
word_count:
typeof p.word_count_target === 'number' && p.word_count_target > 0
? String(p.word_count_target)
: f.word_count,
audience: p.target_audience?.trim() || f.audience,
strict_mode:
p.writing_mode === 'strict' ? true : p.writing_mode === 'loose' ? false : f.strict_mode,
material_source: p.material_source
? p.material_source === 'notebook'
? 'notebook'
: 'general'
: f.material_source,
}));
const handleSave = async () => {
if (!form.name.trim()) {
setFormError('请填写模板名称');
return;
}
setSaving(true);
setFormError(null);
try {
const payload = toPayload(form);
if (editing && id) await update(id, payload);
else await create(payload);
navigate(LIST_PATH);
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
setSaving(false);
}
};
return (
<div className="flex h-full flex-col bg-background">
<header className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(LIST_PATH)}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-base font-semibold">
{editing ? '编辑研究模板' : '新建研究模板'}
</h1>
<p className="text-xs text-muted-foreground">
左侧用问答描述需求,自动填好右侧字段;也可手动编辑。
</p>
</div>
</div>
<div className="flex items-center gap-2">
{formError && <span className="text-sm text-destructive">{formError}</span>}
<Button variant="outline" size="sm" onClick={() => navigate(LIST_PATH)} disabled={saving}>
取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
{saving && <Loader2 className="size-4 animate-spin" />}
保存
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
{/* 左:模板助手问答(同 AI 写作 setup 机制)→ 应用到右侧表单。问答区更宽(约 7:3)。 */}
<div className="min-w-0 flex-[7] border-r border-border">
<WritingSetupChat
onApprove={applyProposalToForm}
title="模板助手"
hint="描述你想要的研究模板,我来帮你填好右侧字段"
/>
</div>
{/* 右:模板表单 */}
<div className="grid min-w-0 flex-[3] grid-cols-2 content-start gap-4 overflow-y-auto p-6">
<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={6}
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>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
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';
const NEW_PATH = '/page/canvas/research-templates/new';
const editPath = (id: string) => `/page/canvas/research-templates/edit/${id}`;
export default function ResearchTemplatesConfigPage() {
const navigate = useNavigate();
const { templates, loading, error, refresh, remove } = useResearchTemplates();
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
void refresh();
void refreshTypes();
}, [refresh, refreshTypes]);
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={() => navigate(NEW_PATH)}>
<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={() => navigate(editPath(t.id))}
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={!!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,8 @@ import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage"; import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage"; import AIWritingPage from "../pages/AIWritingPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage"; import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
import ResearchTemplateEditorPage from "../pages/ResearchTemplateEditorPage";
export default function OpenCanvasRoutes() { export default function OpenCanvasRoutes() {
return ( return (
@@ -11,6 +13,9 @@ export default function OpenCanvasRoutes() {
<Route index element={<CanvasHomePage />} /> <Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} /> <Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} /> <Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
<Route path="research-templates/new" element={<ResearchTemplateEditorPage />} />
<Route path="research-templates/edit/:id" element={<ResearchTemplateEditorPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} /> <Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes> </Routes>
); );
+26 -4
View File
@@ -31,15 +31,31 @@ import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; 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 routeBase = useRouteBase();
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter(); const router = useRouter();
const { agent_id } = useParams<{ const { agent_id: agentIdFromPath } = useParams<{
agent_id: string; 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 // 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 // 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( history.replaceState(
null, null,
"", "",
`${window.location.pathname}${window.location.search}#${routeBase}/agents/${agent_id}/chats/${createdThreadId}`, `${window.location.pathname}${window.location.search}#${agentChatsBase}/${createdThreadId}`,
); );
}, },
onFinish: (state) => { onFinish: (state) => {
@@ -208,8 +224,12 @@ export default function AgentChatPage() {
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push( router.push(
`/page/workspace/agents/${agent_id}/chats/new`, isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
); );
}} }}
> >
@@ -285,6 +305,8 @@ export default function AgentChatPage() {
thread={thread} thread={thread}
paddingBottom={showFollowups ? 96 : 24} paddingBottom={showFollowups ? 96 : 24}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
// 输入框在流内(MessageList 下方),按钮贴对话区底部即可。
scrollButtonClassName="bottom-2"
onClarificationSubmit={handleClarificationSubmit} onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => handleSubmit({ text, files: [] })} onEditResubmit={(text) => handleSubmit({ text, files: [] })}
/> />
@@ -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, type BusinessMapping,
} from "@/strategy-components/api/business-mapping"; } from "@/strategy-components/api/business-mapping";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
export default function BusinessChainMappingPage() { export default function BusinessChainMappingPage() {
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
// 整片脱敏:业务链条映射页展示文本(链条名/描述/席位名/标题)。输入框 Hook 自动跳过。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
const [rows, setRows] = useState<BusinessMapping[]>([]); const [rows, setRows] = useState<BusinessMapping[]>([]);
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
@@ -160,7 +163,7 @@ export default function BusinessChainMappingPage() {
]; ];
return ( 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"> <div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8">
{/* ── 页头 banner ── */} {/* ── 页头 banner ── */}
+14 -4
View File
@@ -34,7 +34,7 @@ import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-rout
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PanelLeftOpen } from "lucide-react"; import { PanelLeftOpen } from "lucide-react";
import { useAgentSelector } from "@/core/agents"; import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
@@ -130,6 +130,10 @@ export default function ChatPage() {
const browserContextAutoSubmitRef = useRef<string | null>(null); const browserContextAutoSubmitRef = useRef<string | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } = const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector(); 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(); const { selectedArtifact } = useArtifacts();
useSpecificChatMode(); useSpecificChatMode();
@@ -142,7 +146,7 @@ export default function ChatPage() {
setHasStartedConversation(false); setHasStartedConversation(false);
setPromptPrefixOn(false); setPromptPrefixOn(false);
// Pre-select the agent the user last started a conversation with so a // 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()); setSelectedAgentId(getLastAgentId());
} }
}, [isNewThread, threadId]); }, [isNewThread, threadId]);
@@ -152,10 +156,14 @@ export default function ChatPage() {
// dead agent. Wait for the list to load before deciding. // dead agent. Wait for the list to load before deciding.
useEffect(() => { useEffect(() => {
if (selectorLoading || !selectedAgentId) return; 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); setSelectedAgentId(null);
} }
}, [selectedAgentId, selectorAgents, selectorLoading]); }, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
@@ -493,6 +501,8 @@ export default function ChatPage() {
thread={thread} thread={thread}
paddingBottom={messageListPaddingBottom} paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
scrollButtonClassName="bottom-40"
onClarificationSubmit={handleClarificationSubmit} onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => onEditResubmit={(text) =>
handleSubmit({ text, files: [] }) handleSubmit({ text, files: [] })
+33 -33
View File
@@ -3,7 +3,6 @@
import { import {
BookMarkedIcon, BookMarkedIcon,
BrainCircuitIcon, BrainCircuitIcon,
CheckIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
} from "lucide-react"; } from "lucide-react";
@@ -23,7 +22,9 @@ import {
} from "@/components/workspace/messages"; } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAgentSelector } from "@/core/agents"; import { useAgentSelector } from "@/core/agents";
import { getAccessToken } from "@/core/auth"; import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -369,7 +370,7 @@ export default function IframeChatPage() {
}, [handleInterrupt, handleSubmit]); }, [handleInterrupt, handleSubmit]);
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
const bottomBarHeightClass = "pb-28"; const bottomBarHeightClass = "pb-24";
const modeButtons = [ const modeButtons = [
{ {
key: "writing", key: "writing",
@@ -377,8 +378,7 @@ export default function IframeChatPage() {
icon: FileTextIcon, icon: FileTextIcon,
active: !!settings.context.writing_mode, active: !!settings.context.writing_mode,
onClick: toggleWritingMode, onClick: toggleWritingMode,
activeClassName: activeClassName: "text-amber-600 hover:text-amber-700",
"border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100",
}, },
{ {
key: "memory", key: "memory",
@@ -386,8 +386,7 @@ export default function IframeChatPage() {
icon: BrainCircuitIcon, icon: BrainCircuitIcon,
active: settings.context.memory_injection_enabled ?? true, active: settings.context.memory_injection_enabled ?? true,
onClick: toggleMemoryInjection, onClick: toggleMemoryInjection,
activeClassName: activeClassName: "text-blue-600 hover:text-blue-700",
"border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100",
}, },
{ {
key: "references", key: "references",
@@ -396,8 +395,7 @@ export default function IframeChatPage() {
active: ragModeEnabled, active: ragModeEnabled,
onClick: toggleRagMode, onClick: toggleRagMode,
disabled: ragModeLocked, disabled: ragModeLocked,
activeClassName: activeClassName: "text-emerald-600 hover:text-emerald-700",
"border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100",
}, },
{ {
key: "pro", key: "pro",
@@ -405,8 +403,7 @@ export default function IframeChatPage() {
icon: GraduationCapIcon, icon: GraduationCapIcon,
active: proModeEnabled, active: proModeEnabled,
onClick: toggleProMode, onClick: toggleProMode,
activeClassName: activeClassName: "text-violet-600 hover:text-violet-700",
"border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100",
}, },
] as const; ] as const;
@@ -496,29 +493,32 @@ export default function IframeChatPage() {
</div> </div>
</div> </div>
<div className="absolute right-0 bottom-0 left-0 z-40 border-t bg-background/95 backdrop-blur"> <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"> <TooltipProvider delayDuration={250}>
{modeButtons.map((button) => { <div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-0 px-0 py-1">
const Icon = button.icon; {modeButtons.map((button) => {
return ( const Icon = button.icon;
<Button return (
key={button.key} <Tooltip key={button.key} content={button.label}>
type="button" <Button
variant="outline" type="button"
disabled={button.disabled || isUploading} variant="ghost"
onClick={button.onClick} size="icon"
className={cn( aria-label={button.label}
"gap-1.5 rounded-full border border-transparent bg-muted/40 text-muted-foreground shadow-none hover:bg-muted hover:text-foreground", disabled={button.disabled || isUploading}
button.active && button.activeClassName, onClick={button.onClick}
button.disabled && "cursor-not-allowed opacity-80", className={cn(
)} "size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
> button.active && button.activeClassName,
{button.active && <CheckIcon className="size-3.5" />} button.disabled && "cursor-not-allowed opacity-80",
<Icon className="size-3.5" /> )}
<span className="text-xs font-normal">{button.label}</span> >
</Button> <Icon className="size-4" />
); </Button>
})} </Tooltip>
</div> );
})}
</div>
</TooltipProvider>
</div> </div>
</main> </main>
</div> </div>
+86 -2
View File
@@ -16,6 +16,12 @@ import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState"; import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
import {
ExtractTemplateManageDialog,
FolderManageDialog,
FolderSelect,
TemplateSelect,
} from "@/components/wiki/KnowledgeManage";
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar"; import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
import { WikiContent } from "@/components/wiki/WikiContent"; import { WikiContent } from "@/components/wiki/WikiContent";
import { WikiHeader } from "@/components/wiki/WikiHeader"; import { WikiHeader } from "@/components/wiki/WikiHeader";
@@ -28,6 +34,7 @@ import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { import {
useKnowledgeActions, useKnowledgeActions,
useKnowledgeDuplicates, useKnowledgeDuplicates,
useKnowledgeFolders,
useKnowledgeNote, useKnowledgeNote,
useKnowledgeNotes, useKnowledgeNotes,
useNoteWikilinks, useNoteWikilinks,
@@ -70,11 +77,13 @@ function CreateNoteDialog({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [folder, setFolder] = useState<string | null>(null);
const reset = () => { const reset = () => {
setTitle(""); setTitle("");
setTags(""); setTags("");
setContent(""); setContent("");
setFolder(null);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -91,6 +100,7 @@ function CreateNoteDialog({
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean), .filter(Boolean),
status: "approved", status: "approved",
folder,
}); });
toast.success("知识已创建"); toast.success("知识已创建");
onOpenChange(false); onOpenChange(false);
@@ -119,6 +129,10 @@ function CreateNoteDialog({
value={tags} value={tags}
onChange={(e) => setTags(e.target.value)} onChange={(e) => setTags(e.target.value)}
/> />
<label className="flex items-center justify-between gap-2 text-sm">
目录
<FolderSelect value={folder} onChange={setFolder} triggerClassName="w-[14rem]" />
</label>
<Textarea <Textarea
placeholder="Markdown 正文" placeholder="Markdown 正文"
rows={12} rows={12}
@@ -152,6 +166,8 @@ function BatchImportDialog({
const [skipExisting, setSkipExisting] = useState(true); const [skipExisting, setSkipExisting] = useState(true);
// Optional model override for extraction (null = configured default). // Optional model override for extraction (null = configured default).
const [model, setModel] = useState<string | null>(null); const [model, setModel] = useState<string | null>(null);
const [template, setTemplate] = useState<string | null>(null);
const [folder, setFolder] = useState<string | null>(null);
const handleRun = async () => { const handleRun = async () => {
try { try {
@@ -160,8 +176,21 @@ function BatchImportDialog({
skip_existing: skipExisting, skip_existing: skipExisting,
status: "approved", status: "approved",
include_sources: true, include_sources: true,
// Run in the background so the request returns immediately — a large
// batch otherwise blocks past the gateway timeout and shows "request
// failed". Threads stream into the list as they finish.
background: true,
model_name: model, model_name: model,
template_id: template,
folder,
}); });
if (result.queued) {
toast.success(
`已转入后台导入 ${result.processed} 个线程,完成后会陆续出现在左侧列表(可稍后刷新查看)`,
);
onOpenChange(false);
return;
}
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`; const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
if (result.llm_fallback && result.llm_fallback > 0) { if (result.llm_fallback && result.llm_fallback > 0) {
// The model failed on some threads — they were saved via rule-based // The model failed on some threads — they were saved via rule-based
@@ -217,6 +246,25 @@ function BatchImportDialog({
triggerClassName="h-9 w-[11rem]" triggerClassName="h-9 w-[11rem]"
/> />
</label> </label>
<label className="flex items-center justify-between gap-2 text-sm">
抽取模板
<TemplateSelect
value={template}
onChange={setTemplate}
scope="thread"
disabled={batchIngest.isPending}
triggerClassName="w-[11rem]"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
归入目录
<FolderSelect
value={folder}
onChange={setFolder}
disabled={batchIngest.isPending}
triggerClassName="w-[11rem]"
/>
</label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -244,12 +292,16 @@ function ImportFileDialog({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [model, setModel] = useState<string | null>(null); const [model, setModel] = useState<string | null>(null);
const [template, setTemplate] = useState<string | null>(null);
const [folder, setFolder] = useState<string | null>(null);
const reset = () => { const reset = () => {
setFile(null); setFile(null);
setTitle(""); setTitle("");
setTags(""); setTags("");
setModel(null); setModel(null);
setTemplate(null);
setFolder(null);
}; };
const handleImport = async () => { const handleImport = async () => {
@@ -265,6 +317,8 @@ function ImportFileDialog({
tags: tags.trim() || undefined, tags: tags.trim() || undefined,
status: "approved", status: "approved",
model_name: model, model_name: model,
template_id: template,
folder,
}, },
}); });
const count = result.notes?.length ?? 1; const count = result.notes?.length ?? 1;
@@ -330,6 +384,25 @@ function ImportFileDialog({
triggerClassName="h-9 w-[11rem]" triggerClassName="h-9 w-[11rem]"
/> />
</label> </label>
<label className="flex items-center justify-between gap-2 text-sm">
抽取模板
<TemplateSelect
value={template}
onChange={setTemplate}
scope="document"
disabled={ingestFile.isPending}
triggerClassName="w-[11rem]"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
归入目录
<FolderSelect
value={folder}
onChange={setFolder}
disabled={ingestFile.isPending}
triggerClassName="w-[11rem]"
/>
</label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -478,6 +551,8 @@ export default function KnowledgeBasePage() {
const [batchOpen, setBatchOpen] = useState(false); const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false); const [dedupOpen, setDedupOpen] = useState(false);
const [foldersOpen, setFoldersOpen] = useState(false);
const [templatesOpen, setTemplatesOpen] = useState(false);
const [vaultPath, setVaultPath] = useState<string | null>(null); const [vaultPath, setVaultPath] = useState<string | null>(null);
const params = useMemo( const params = useMemo(
@@ -485,14 +560,19 @@ export default function KnowledgeBasePage() {
keyword: debouncedKeyword || undefined, keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType, source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status, status: status === "all" ? undefined : status,
limit: 500, limit: 1000,
}), }),
[debouncedKeyword, sourceType, status], [debouncedKeyword, sourceType, status],
); );
const { data, isLoading } = useKnowledgeNotes(params); const { data, isLoading } = useKnowledgeNotes(params);
const { data: folders = [] } = useKnowledgeFolders();
const notes = data?.items ?? []; const notes = data?.items ?? [];
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]); const folderPaths = useMemo(() => folders.map((f) => f.path), [folders]);
const tree = useMemo(
() => buildKnowledgeTree(notes, folderPaths),
[notes, folderPaths],
);
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId); const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId); const { data: wikiLinks } = useNoteWikilinks(noteId);
@@ -571,6 +651,8 @@ export default function KnowledgeBasePage() {
onDedup={() => setDedupOpen(true)} onDedup={() => setDedupOpen(true)}
onGraph={() => navigate("/page/workspace/knowledge/graph")} onGraph={() => navigate("/page/workspace/knowledge/graph")}
onCreate={() => setCreateOpen(true)} onCreate={() => setCreateOpen(true)}
onManageFolders={() => setFoldersOpen(true)}
onManageTemplates={() => setTemplatesOpen(true)}
/> />
<div <div
@@ -636,6 +718,8 @@ export default function KnowledgeBasePage() {
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} /> <ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} /> <BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} /> <DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
<FolderManageDialog open={foldersOpen} onOpenChange={setFoldersOpen} />
<ExtractTemplateManageDialog open={templatesOpen} onOpenChange={setTemplatesOpen} />
<VaultPageDialog <VaultPageDialog
path={vaultPath} path={vaultPath}
onOpenChange={(open) => { onOpenChange={(open) => {
+34
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useNavigate } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
@@ -11,6 +12,11 @@ import {
readExternalLoginParams, readExternalLoginParams,
resolveThemeFromQuery, resolveThemeFromQuery,
} from '@/core/auth/external-login'; } from '@/core/auth/external-login';
import {
fetchCopTaskDetail,
persistCopTaskDetail,
resolveTaskDeeplinkTarget,
} from '@/core/auth/task-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import { import {
@@ -34,6 +40,7 @@ type LoginStatus = 'loading' | 'success' | 'error';
export default function LoginPage() { export default function LoginPage() {
const { username } = useParams<{ username?: string }>(); const { username } = useParams<{ username?: string }>();
const router = useRouter(); const router = useRouter();
const navigate = useNavigate();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { setTheme } = useTheme(); const { setTheme } = useTheme();
const sidebarEnabled = useMemo( const sidebarEnabled = useMemo(
@@ -60,6 +67,9 @@ export default function LoginPage() {
const gatePassword = searchParams.get('password') ?? ''; const gatePassword = searchParams.get('password') ?? '';
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开) // token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
const authToken = searchParams.get('authToken')?.trim() ?? ''; const authToken = searchParams.get('authToken')?.trim() ?? '';
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
const taskId = searchParams.get('taskId')?.trim() ?? '';
const goPath = searchParams.get('goPath')?.trim() ?? '';
const externalParams = useMemo( const externalParams = useMemo(
() => ({ () => ({
theme: searchParams.get('theme') ?? '', theme: searchParams.get('theme') ?? '',
@@ -145,6 +155,27 @@ export default function LoginPage() {
persistAccessToken(accessToken); persistAccessToken(accessToken);
persistSidebarEnabled(sidebarEnabled); persistSidebarEnabled(sidebarEnabled);
setStatus('success'); 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); router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
} catch (error) { } catch (error) {
if (cancelled) { if (cancelled) {
@@ -164,6 +195,7 @@ export default function LoginPage() {
}, [ }, [
resolvedUsername, resolvedUsername,
router, router,
navigate,
sidebarEnabled, sidebarEnabled,
yUserId, yUserId,
toolToken, toolToken,
@@ -171,6 +203,8 @@ export default function LoginPage() {
onlineTicket, onlineTicket,
authToken, authToken,
gatePassword, gatePassword,
taskId,
goPath,
]); ]);
return ( return (
+5
View File
@@ -0,0 +1,5 @@
import { PositionManagementPage } from "@/components/workspace/admin/position-management-page";
export default function PositionsPage() {
return <PositionManagementPage />;
}
@@ -8,11 +8,15 @@ import {
Loader2Icon, Loader2Icon,
PencilIcon, PencilIcon,
PlusIcon, PlusIcon,
TagIcon,
Trash2Icon, Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ResourceTagDialog } from "@/components/workspace/tags/resource-tag-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { import {
Dialog, Dialog,
@@ -24,9 +28,9 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
useAdminRecommendedQuestions,
useCreateRecommendedQuestion, useCreateRecommendedQuestion,
useDeleteRecommendedQuestion, useDeleteRecommendedQuestion,
useRecommendedQuestions,
useReorderRecommendedQuestions, useReorderRecommendedQuestions,
useUpdateRecommendedQuestion, useUpdateRecommendedQuestion,
} from "@/core/recommended-questions/hooks"; } from "@/core/recommended-questions/hooks";
@@ -65,7 +69,7 @@ export default function RecommendedQuestionsPage({
embedded?: boolean; embedded?: boolean;
} = {}) { } = {}) {
const { theme } = useTheme(); const { theme } = useTheme();
const { questions, isLoading, error } = useRecommendedQuestions(); const { questions, isLoading, error } = useAdminRecommendedQuestions();
const createMutation = useCreateRecommendedQuestion(); const createMutation = useCreateRecommendedQuestion();
const updateMutation = useUpdateRecommendedQuestion(); const updateMutation = useUpdateRecommendedQuestion();
const deleteMutation = useDeleteRecommendedQuestion(); const deleteMutation = useDeleteRecommendedQuestion();
@@ -75,11 +79,31 @@ export default function RecommendedQuestionsPage({
const [formTitle, setFormTitle] = useState(""); const [formTitle, setFormTitle] = useState("");
const [formContent, setFormContent] = useState(""); const [formContent, setFormContent] = useState("");
const [deleteTarget, setDeleteTarget] = useState<RecommendedQuestion | null>(null); const [deleteTarget, setDeleteTarget] = useState<RecommendedQuestion | null>(null);
const [tagTarget, setTagTarget] = useState<RecommendedQuestion | null>(null);
// drag state // drag state
const [dragIndex, setDragIndex] = useState<number | null>(null); const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// 名称 / 标签搜索
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const isFiltering = search.trim() !== "" || tagIds.length > 0;
const needle = search.trim().toLowerCase();
const visibleQuestions = isFiltering
? questions.filter((q) => {
const matchesText =
needle === "" ||
q.title.toLowerCase().includes(needle) ||
q.content.toLowerCase().includes(needle);
const matchesTags =
tagIds.length === 0 ||
(q.tags ?? []).some((t) => tagIds.includes(t.id));
return matchesText && matchesTags;
})
: questions;
function openAdd() { function openAdd() {
setFormTitle(""); setFormTitle("");
setFormContent(""); setFormContent("");
@@ -200,6 +224,16 @@ export default function RecommendedQuestionsPage({
</Button> </Button>
</div> </div>
{!isLoading && !error && questions.length > 0 && (
<TagSearchBar
targetType="recommended_question"
searchPlaceholder="搜索标题 / 内容"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
/>
)}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" /> <Loader2Icon className="text-muted-foreground size-6 animate-spin" />
@@ -216,15 +250,19 @@ export default function RecommendedQuestionsPage({
添加第一个问题 添加第一个问题
</Button> </Button>
</div> </div>
) : visibleQuestions.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-sm dark:text-[#7FACF2] dark:!border-[#213F62]">
<p>没有匹配的推荐问题</p>
</div>
) : ( ) : (
<div className="flex flex-col gap-2 "> <div className="flex flex-col gap-2 ">
{questions.map((q, index) => ( {visibleQuestions.map((q, index) => (
<div <div
key={q.id} key={q.id}
draggable draggable={!isFiltering}
onDragStart={() => handleDragStart(index)} onDragStart={() => !isFiltering && handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)} onDragOver={(e) => !isFiltering && handleDragOver(e, index)}
onDrop={() => handleDrop(index)} onDrop={() => !isFiltering && handleDrop(index)}
onDragEnd={() => { onDragEnd={() => {
setDragIndex(null); setDragIndex(null);
setDragOverIndex(null); setDragOverIndex(null);
@@ -235,7 +273,13 @@ export default function RecommendedQuestionsPage({
: "border-[#e4e4e7]" : "border-[#e4e4e7]"
}`} }`}
> >
<GripVerticalIcon className="text-muted-foreground/40 mt-0.5 size-4 shrink-0 cursor-grab active:cursor-grabbing dark:text-[#566B7F]" /> <GripVerticalIcon
className={`text-muted-foreground/40 mt-0.5 size-4 shrink-0 dark:text-[#566B7F] ${
isFiltering
? "cursor-not-allowed opacity-40"
: "cursor-grab active:cursor-grabbing"
}`}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[#18181b] dark:text-white"> <div className="truncate text-sm font-medium text-[#18181b] dark:text-white">
{q.title} {q.title}
@@ -243,8 +287,21 @@ export default function RecommendedQuestionsPage({
<div className="text-muted-foreground mt-0.5 line-clamp-2 text-xs dark:text-[#7FACF2]"> <div className="text-muted-foreground mt-0.5 line-clamp-2 text-xs dark:text-[#7FACF2]">
{q.content} {q.content}
</div> </div>
{(q.tags?.length ?? 0) > 0 && (
<TagChipList tags={q.tags!} className="mt-2" />
)}
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => setTagTarget(q)}
disabled={isSaving}
title="打标签(按岗位分发)"
>
<TagIcon className="size-3.5" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -308,6 +365,17 @@ export default function RecommendedQuestionsPage({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 打标签弹窗(按岗位分发推荐问题) */}
{tagTarget && (
<ResourceTagDialog
open={tagTarget !== null}
onOpenChange={(o) => !o && setTagTarget(null)}
targetType="recommended_question"
targetId={tagTarget.id}
title={`为「${tagTarget.title}」打标签`}
/>
)}
{/* 删除确认弹窗 */} {/* 删除确认弹窗 */}
<Dialog <Dialog
open={deleteTarget !== null} open={deleteTarget !== null}
@@ -0,0 +1,141 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useSkillCompressionSettings,
useUpdateSkillCompressionSettings,
} from "@/core/system-settings/hooks";
/**
* 管理员「技能压缩」配置页。
*
* 开启后,只有被标记为「常驻(免压缩)」的技能会全文注入系统提示词;其余已启用
* 技能会被压缩(从提示词正文剔除),由智能体在需要时通过 `search_skills` 工具
* 按关键词检索调出。关闭时行为不变 —— 所有已启用技能全文注入。
*/
export default function SkillCompressionSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = useSkillCompressionSettings();
const update = useUpdateSkillCompressionSettings();
const [enabled, setEnabled] = useState(false);
const [keepIndex, setKeepIndex] = useState(true);
const [searchTopN, setSearchTopN] = useState(5);
const initializedRef = useRef(false);
useEffect(() => {
if (!data || initializedRef.current) return;
setEnabled(data.enabled);
setKeepIndex(data.keep_index_in_prompt);
setSearchTopN(data.search_top_n);
initializedRef.current = true;
}, [data]);
const handleSave = async () => {
const topN = Math.max(1, Math.min(Math.round(searchTopN) || 5, 50));
try {
await update.mutateAsync({
enabled,
keep_index_in_prompt: keepIndex,
search_top_n: topN,
});
setSearchTopN(topN);
toast.success("已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
return (
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
技能压缩配置
</h1>
<p className="text-muted-foreground mt-1 text-sm">
技能过多会让系统提示词变得臃肿、增加 token 消耗。开启「技能压缩」后,只有被标记为
「常驻(免压缩)」的技能会全文注入系统提示词;其余已启用技能会被压缩,智能体在需要时
通过 <code>search_skills</code> 工具按关键词检索调出。关闭时行为不变 ——
所有已启用技能全文注入。某个技能是否常驻,在该技能的详情页单独开启。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">启用技能压缩</div>
<div className="text-muted-foreground mt-0.5 text-xs">
关闭时,所有已启用技能照旧全文注入系统提示词(默认行为)。
</div>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">保留技能名索引</div>
<div className="text-muted-foreground mt-0.5 text-xs">
开启后,被压缩的技能仍以「仅名字」的紧凑列表出现在提示词里,帮助智能体知道存在
哪些技能;关闭则完全隐藏,只能靠 search_skills 检索发现。
</div>
</div>
<Switch
checked={keepIndex}
disabled={!enabled}
onCheckedChange={setKeepIndex}
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">检索返回条数</p>
<p className="text-muted-foreground text-xs">
search_skills 工具每次最多返回的匹配技能数量(1–50)。
</p>
<Input
type="number"
min={1}
max={50}
value={searchTopN}
disabled={!enabled}
onChange={(e) => setSearchTopN(Number(e.target.value))}
className="w-28"
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
保存
</Button>
</div>
</div>
)}
</div>
);
}
+77 -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 { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar"; import { SidebarProvider } from "@/components/ui/sidebar";
@@ -19,6 +19,8 @@ import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainList
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage"; import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css"; import "@/roundtable-planning/styles/roundtable-planning.css";
import AgentChatPage from "./AgentChatPage"; import AgentChatPage from "./AgentChatPage";
import { AgentModuleRoutes } from "./AgentModulePage";
import { AGENT_MODULES } from "@/components/workspace/agents/agent-module-sidebar";
import AgentsPage from "./AgentsPage"; import AgentsPage from "./AgentsPage";
import BusinessChainMappingPage from "./BusinessChainMappingPage"; import BusinessChainMappingPage from "./BusinessChainMappingPage";
import ChatPage from "./ChatPage"; import ChatPage from "./ChatPage";
@@ -41,6 +43,7 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage"; import ScheduledTasksPage from "./ScheduledTasksPage";
import SkillEvolutionPage from "./SkillEvolutionPage"; import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage"; import SkillsPage from "./SkillsPage";
import PositionsPage from "./PositionsPage";
import TagsPage from "./TagsPage"; import TagsPage from "./TagsPage";
import UsersPage from "./UsersPage"; import UsersPage from "./UsersPage";
import LlmMetricsPage from "./LlmMetricsPage"; import LlmMetricsPage from "./LlmMetricsPage";
@@ -48,6 +51,20 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage"; import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes"; 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() { export default function StrategyRoutes() {
return ( return (
<RouteBaseProvider value="/page/strategy"> <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 path="task-management" element={<TaskManagement />}>
<Route index element={<Navigate to="xk-task-jc" replace />} /> <Route index element={<Navigate to="xk-task-jc" replace />} />
<Route <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 <Route
path="plan-task/plan-task-fx" path="plan-task/plan-task-fx"
element={ 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 <Route
path="plan-task/plan-task-action" path="plan-task/plan-task-action"
element={ 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-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} />
<Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} /> <Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} />
<Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} /> <Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} />
@@ -289,6 +355,16 @@ export default function StrategyRoutes() {
path="admin/tags" path="admin/tags"
element={<TagsPage />} element={<TagsPage />}
/> />
<Route
path="admin/positions"
element={
getStoredAuth()?.system_role === "admin" ? (
<PositionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes> </Routes>
</div> </div>
</SidebarProvider> </SidebarProvider>
@@ -210,8 +210,15 @@ function SourcesDialog({
} }
// ── 主页面 ────────────────────────────────────────────── // ── 主页面 ──────────────────────────────────────────────
export default function StudioNotebookDetailPage() { export default function StudioNotebookDetailPage({
const { notebookId } = useParams<{ notebookId: string }>(); notebookId: notebookIdProp,
}: {
/** 内嵌使用(如课题研究右侧)时直接传入;缺省则从路由参数取。 */
notebookId?: number;
} = {}) {
const params = useParams<{ notebookId: string }>();
const notebookId =
notebookIdProp != null ? String(notebookIdProp) : params.notebookId;
const { theme } = useTheme(); // 跳转「我的空间」时带上当前主题 key const { theme } = useTheme(); // 跳转「我的空间」时带上当前主题 key
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
const { models } = useModels(); const { models } = useModels();
+33 -11
View File
@@ -44,21 +44,17 @@ function NotebookCard({
isDark, isDark,
onRename, onRename,
onDelete, onDelete,
onOpen,
}: { }: {
nb: StudioNotebook; nb: StudioNotebook;
isDark: boolean; isDark: boolean;
onRename: (nb: StudioNotebook) => void; onRename: (nb: StudioNotebook) => void;
onDelete: (nb: StudioNotebook) => void; onDelete: (nb: StudioNotebook) => void;
/** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */
onOpen?: (id: number) => void;
}) { }) {
return ( const inner = (
<div <>
className={`group relative flex flex-col rounded-[20px] transition-colors ${isDark ? "bg-[#1e2330] hover:bg-[#262c3b]" : "bg-[#f2f2f2] hover:bg-[#e8e8e8]"}`}
style={{ minHeight: 180 }}
>
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
className="flex flex-1 flex-col p-5"
>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none"> <div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
{nb.avatar_url ? ( {nb.avatar_url ? (
@@ -76,7 +72,27 @@ function NotebookCard({
</span> </span>
<span className={`text-[13px] ${isDark ? "text-[#8b93a7]" : "text-[#666]"}`}>{nb.source_count} 个来源</span> <span className={`text-[13px] ${isDark ? "text-[#8b93a7]" : "text-[#666]"}`}>{nb.source_count} 个来源</span>
</div> </div>
</Link> </>
);
return (
<div className={`group relative flex flex-col rounded-[20px] transition-colors ${isDark ? "bg-[#1e2330] hover:bg-[#262c3b]" : "bg-[#f2f2f2] 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 */} {/* three-dot menu — absolute so it doesn't shift layout */}
<div className="absolute right-4 top-4"> <div className="absolute right-4 top-4">
@@ -108,7 +124,12 @@ function NotebookCard({
); );
} }
export default function StudioNotebooksPage() { export default function StudioNotebooksPage({
onOpenNotebook,
}: {
/** 内嵌使用(课题研究右侧)时传入:点击空间卡片回调,不跳路由。 */
onOpenNotebook?: (id: number) => void;
} = {}) {
const { notebooks, isLoading, isError } = useStudioNotebooks(); const { notebooks, isLoading, isError } = useStudioNotebooks();
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
@@ -216,6 +237,7 @@ export default function StudioNotebooksPage() {
isDark={isDark} isDark={isDark}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }} onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }} onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
onOpen={onOpenNotebook}
/> />
))} ))}
</div> </div>
+50 -3
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from 'react'; 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'; import { getStoredAuth } from '@/core/auth';
@@ -32,6 +32,7 @@ import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage'; import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage'; import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage'; import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage'; import SkillEvolutionPage from './SkillEvolutionPage';
import SkillsPage from './SkillsPage'; import SkillsPage from './SkillsPage';
import UsersPage from './UsersPage'; import UsersPage from './UsersPage';
@@ -39,6 +40,7 @@ import AdminUserActivityPage from './AdminUserActivityPage';
import AdminMemoryPage from './AdminMemoryPage'; import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage'; import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage'; import TagsPage from './TagsPage';
import PositionsPage from './PositionsPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage'; import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage'; import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import StudioNotebooksPage from './StudioNotebooksPage'; import StudioNotebooksPage from './StudioNotebooksPage';
@@ -87,6 +89,10 @@ export function WorkspaceLayout({
insetOverflowHidden?: boolean; insetOverflowHidden?: boolean;
pageSidebarDark?: boolean; pageSidebarDark?: boolean;
}) { }) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const isGeneralQa = useLocation().pathname.startsWith("/page/workspace/chats");
const pageSidebarWidth = pageSidebarHidden const pageSidebarWidth = pageSidebarHidden
? '0rem' ? '0rem'
: pageSidebarDark : pageSidebarDark
@@ -113,7 +119,9 @@ export function WorkspaceLayout({
} }
key={String(compactWorkspaceSidebar)} key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed} data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={defaultHistorySidebarOpen ?? !compactWorkspaceSidebar} defaultOpen={
defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar)
}
> >
{!workspaceSidebarHidden && ( {!workspaceSidebarHidden && (
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} /> <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({ function NewChatEntryRoute({
layoutProps, layoutProps,
}: { }: {
@@ -157,7 +180,7 @@ function NewChatEntryRoute({
return ( return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen> <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime> <ChatRuntime>
<ChatPage /> <NewChatContent />
</ChatRuntime> </ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
); );
@@ -475,6 +498,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="admin/positions"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PositionsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="settings/recommended-questions" path="settings/recommended-questions"
element={ element={
@@ -523,6 +558,18 @@ export default function WorkspaceRoutes({
) )
} }
/> />
<Route
path="settings/skill-compression"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<SkillCompressionSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="settings/llm-metrics" path="settings/llm-metrics"
element={ element={
@@ -29,6 +29,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
@@ -212,9 +213,11 @@ export function BusinessChainListDialog({
onOpenChange, onOpenChange,
onConfigure, onConfigure,
}: BusinessChainListDialogProps) { }: BusinessChainListDialogProps) {
// 整片脱敏:弹窗内链条列表(链条名/描述/席位名/标题)。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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"> <DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<LayersIcon className="size-5 text-primary" /> <LayersIcon className="size-5 text-primary" />
@@ -26,6 +26,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog"; import { DialogFooter } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
import { filterRecommendCandidates } from "../api/recommend"; import { filterRecommendCandidates } from "../api/recommend";
@@ -161,9 +162,12 @@ export function BusinessChainPicker({
onConfirm({ agents, mode: "chain", chain: chainMeta }); onConfirm({ agents, mode: "chain", chain: chainMeta });
}; };
// 整片脱敏:链条卡片区(链条名/描述/席位智能体名)。输入框不受影响(Hook 跳过)。
const dzRef = useDesensitizeSubtree<HTMLElement>();
return ( 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"> <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) => ( {(["mine", "public"] as const).map((s) => (
@@ -21,6 +21,7 @@
*/ */
import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars"; import { getAvatar } from "./Avatars";
@@ -137,7 +138,7 @@ export function DagFlowChart({
return ( return (
<FlowNode <FlowNode
key={agentId} key={agentId}
label={meta.name} label={desensitize(meta.name)}
avatarType={meta.avatarType} avatarType={meta.avatarType}
state={state} state={state}
compact={compact} compact={compact}
@@ -20,6 +20,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { CheckIcon, Loader2Icon, Network, X } from "lucide-react"; import { CheckIcon, Loader2Icon, Network, X } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars"; import { getAvatar } from "./Avatars";
@@ -400,7 +401,7 @@ export function DagFlowMiniPanel({
return ( return (
<MiniNode <MiniNode
key={agentId} key={agentId}
label={meta.name} label={desensitize(meta.name)}
avatarType={meta.avatarType} avatarType={meta.avatarType}
state={state} state={state}
/> />
@@ -26,6 +26,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars"; import { getAvatar } from "./Avatars";
@@ -236,7 +237,7 @@ export function DispatchChainModal({
done={node.state === "done"} done={node.state === "done"}
/> />
<FlowNode <FlowNode
label={node.name} label={desensitize(node.name)}
avatarType={node.avatarType} avatarType={node.avatarType}
state={node.state} state={node.state}
order={node.order} order={node.order}
@@ -277,7 +278,7 @@ export function DispatchChainModal({
<div key={d.id} className="flex gap-2.5"> <div key={d.id} className="flex gap-2.5">
<div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div> <div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div>
<div className="min-w-0 flex-1"> <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"> <div className="prose prose-sm max-w-none break-words text-sm leading-relaxed text-foreground/80 dark:prose-invert">
<MarkdownContent>{d.content || "…"}</MarkdownContent> <MarkdownContent>{d.content || "…"}</MarkdownContent>
</div> </div>
@@ -32,6 +32,7 @@ import {
type ThreadIdMap, type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { import {
Step3ReportMode, 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", lit ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground",
)} )}
> >
{seat.displayName} {desensitize(seat.displayName)}
</span> </span>
<span className="block truncate text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground"> <span className="block truncate text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground">
{lit ? `${seat.delivery!.length} 字` : "无交付"} {lit ? `${seat.delivery!.length} 字` : "无交付"}
@@ -771,7 +772,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
{consensusText ? ( {consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed dark:text-foreground/80"> <div className="text-xs text-slate-700 leading-relaxed dark:text-foreground/80">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText} {desensitize(consensusText)}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) : ( ) : (
@@ -830,7 +831,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
{getAvatar(seat.avatarType, "h-5.5 w-5.5")} {getAvatar(seat.avatarType, "h-5.5 w-5.5")}
</span> </span>
<span className="text-xs font-black text-slate-800 truncate dark:text-foreground"> <span className="text-xs font-black text-slate-800 truncate dark:text-foreground">
{seat.displayName} {desensitize(seat.displayName)}
</span> </span>
</div> </div>
<span className="text-[10px] text-slate-400 font-mono shrink-0 dark:text-muted-foreground"> <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]} remarkPlugins={[remarkGfm]}
components={markdownComponents} components={markdownComponents}
> >
{seat.delivery!} {desensitize(seat.delivery!)}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">该席位暂无交付内容。</p> <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 remarkGfm from "remark-gfm";
import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react"; import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface MessageBubbleProps { export interface MessageBubbleProps {
@@ -58,7 +59,8 @@ export function MessageBubble({
senderNameClassName, senderNameClassName,
)} )}
> >
{senderName} {/* 智能体名称需脱敏(消息列表里唯一要脱敏的就是名称)。 */}
{desensitize(senderName)}
</span> </span>
{badge && ( {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"> <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。 * 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。
* 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。 * 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。
*/ */
// 消息列表正文**不脱敏**(仅名称脱敏);MarkdownContent 是会商对话气泡正文渲染器,故原文渲染。
export function MarkdownContent({ children }: { children: string }) { export function MarkdownContent({ children }: { children: string }) {
return ( return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={sharedMarkdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={sharedMarkdownComponents}>
@@ -11,6 +11,7 @@
*/ */
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react"; import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "@/roundtable-planning/components/Avatars"; import { getAvatar } from "@/roundtable-planning/components/Avatars";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration"; import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration";
@@ -42,7 +43,7 @@ export function ParallelSeatStrip({ batch, selectedSeats, onToggleSeat }: Parall
key={seat.agentId} key={seat.agentId}
type="button" type="button"
onClick={() => onToggleSeat(seat.agentId)} onClick={() => onToggleSeat(seat.agentId)}
title={`${seat.displayName}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`} title={`${desensitize(seat.displayName)}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`}
className={cn( className={cn(
"relative shrink-0 rounded-full transition", "relative shrink-0 rounded-full transition",
isSelected ? "ring-2 ring-primary ring-offset-1" : "ring-1 ring-transparent hover:ring-slate-300", 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 { Button } from "@/components/ui/button";
import { listAgents, type Agent } from "@/core/agents"; import { listAgents, type Agent } from "@/core/agents";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -1122,7 +1123,7 @@ function AgentCard({
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <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 && ( {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"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
推荐 推荐
@@ -1131,12 +1132,12 @@ function AgentCard({
</div> </div>
{candidate.description && ( {candidate.description && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2"> <div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{candidate.description} {desensitize(candidate.description)}
</div> </div>
)} )}
{reason ? ( {reason ? (
<div className="text-xs text-primary/80 mt-1 italic"> <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> </div>
) : streaming ? ( ) : streaming ? (
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div> <div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
@@ -56,6 +56,7 @@ import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToB
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent"; 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 { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants"; import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
import type { import type {
@@ -149,10 +150,13 @@ export function Step1Panel(props: Step1PanelProps) {
setAttachedFiles([]); setAttachedFiles([]);
}; };
// 顶部标题栏整片脱敏(消息列表正文在下方,不在此容器内,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
return ( return (
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
{/* Dialogue Header */} {/* 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"> <div className="flex items-center gap-2">
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} /> <MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h3 className="text-sm font-extrabold text-slate-900"> <h3 className="text-sm font-extrabold text-slate-900">
@@ -81,6 +81,8 @@ import {
getAvatarTypeFor, getAvatarTypeFor,
type RoleEntry, type RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants"; } 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 { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
@@ -405,10 +407,13 @@ export function Step2Panel(props: Step2PanelProps) {
isOrchestrating || isOrchestrating ||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true); step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
// 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
const chatBody = ( const chatBody = (
<> <>
{/* Headline */} {/* 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"> <div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} /> <Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div> <div>
@@ -584,7 +589,7 @@ export function Step2Panel(props: Step2PanelProps) {
/> />
) : ( ) : (
<div className="py-6 text-center text-xs text-slate-400"> <div className="py-6 text-center text-xs text-slate-400">
{seat.displayName} · 等待输出… {desensitize(seat.displayName)} · 等待输出…
</div> </div>
)} )}
</div> </div>
@@ -599,7 +604,7 @@ export function Step2Panel(props: Step2PanelProps) {
<span className="roundtable-chat-meta font-medium text-slate-600"> <span className="roundtable-chat-meta font-medium text-slate-600">
仅显示{" "} 仅显示{" "}
<span className="font-bold text-primary"> <span className="font-bold text-primary">
{seatFilterLabel(seatFilter)} {desensitize(seatFilterLabel(seatFilter))}
</span>{" "} </span>{" "}
的发言 的发言
</span> </span>
@@ -615,7 +620,7 @@ export function Step2Panel(props: Step2PanelProps) {
)} )}
{seatFilter && visibleDialogues.length === 0 ? ( {seatFilter && visibleDialogues.length === 0 ? (
<div className="py-10 text-center text-xs text-slate-400"> <div className="py-10 text-center text-xs text-slate-400">
没有匹配「{seatFilterLabel(seatFilter)}」的消息 没有匹配「{desensitize(seatFilterLabel(seatFilter))}」的消息
</div> </div>
) : ( ) : (
visibleDialogues.map((msg) => ( visibleDialogues.map((msg) => (
@@ -878,7 +883,7 @@ export function Step2Message({
title={ title={
isAvatarSelected isAvatarSelected
? "取消筛选,显示全部发言" ? "取消筛选,显示全部发言"
: `只看 ${msg.sender} 的发言` : `只看 ${desensitize(msg.sender)} 的发言`
} }
className={cn( className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60", "shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
@@ -116,6 +116,8 @@ export function Step2SandboxLayout({
threadId={previewThreadId} threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined} onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
allowVirtualMarkdownEdit allowVirtualMarkdownEdit
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
disableDesensitize
/> />
) : ( ) : (
<div className="relative flex size-full justify-center"> <div className="relative flex size-full justify-center">
@@ -35,6 +35,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { desensitizeDeep } from "@/lib/desensitize";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp"; import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context"; import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
@@ -101,10 +102,12 @@ export function Step3DashboardMode({
} = dashboard; } = dashboard;
// 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。 // 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。
const report = useMemo( // 整体深度脱敏后再渲染——大屏(DashboardApp)与自包含 HTML(iframe/新窗口/分享/下载)
() => dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null), // 都从这份 report 派生,故在此一处脱敏即覆盖大屏的所有展示文本(会商页非沙箱内容需脱敏)。
[dashboard.data, savedReport?.dashboardJson], const report = useMemo(() => {
); const raw = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null);
return raw ? desensitizeDeep(raw) : raw;
}, [dashboard.data, savedReport?.dashboardJson]);
// 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。 // 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。
const dashboardData = useMemo( const dashboardData = useMemo(
@@ -44,6 +44,7 @@ import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/Co
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode"; import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode";
@@ -407,7 +408,7 @@ export function Step3ReportMode({
> >
{consensusText ? ( {consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText} {desensitize(consensusText)}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<p className="text-xs text-slate-400 italic">暂无总控结论。</p> <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"> <div className="flex items-center gap-2 min-w-0">
<ListOrdered className="w-3.5 h-3.5 text-slate-400 shrink-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> </div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span> <span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button> </button>
{open && ( {open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white"> <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}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"} {desensitize(seat.content || "(空)")}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
@@ -51,6 +51,7 @@ import {
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
@@ -449,7 +450,7 @@ export function Step3SummaryMode({
> >
{consensusText ? ( {consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText} {desensitize(consensusText)}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<p className="text-xs text-slate-400 italic">暂无总控结论。</p> <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"> <div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-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> </div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span> <span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button> </button>
{open && ( {open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white"> <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}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"} {desensitize(seat.content || "(空)")}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
@@ -24,6 +24,7 @@ import {
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought"; import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block"; import { CodeBlock } from "@/components/ai-elements/code-block";
import { desensitize } from "@/lib/desensitize";
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble"; import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
/** /**
@@ -52,7 +53,7 @@ export function renderStepArgs(
return ( return (
<> <>
{displayName && ( {displayName && (
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult> <ChainOfThoughtSearchResult>{desensitize(displayName)}</ChainOfThoughtSearchResult>
)} )}
{task && ( {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"> <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 { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
@@ -134,6 +135,8 @@ type ActiveDrag =
export default function BusinessChainEditorPage() { export default function BusinessChainEditorPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// 整片脱敏:链条配置页展示文本(链条名/描述/席位名/标题)。可编辑的 input/textarea 不脱敏(Hook 跳过),保证编辑用原文。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
// ─── 数据 ──────────────────────────────────────────────────────────── // ─── 数据 ────────────────────────────────────────────────────────────
const [pool, setPool] = useState<RecommendCandidate[]>([]); const [pool, setPool] = useState<RecommendCandidate[]>([]);
@@ -804,7 +807,7 @@ export default function BusinessChainEditorPage() {
); );
return ( 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"> <header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
<button <button
@@ -62,6 +62,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useRouteNavigate } from "@/core/route-base"; import { useRouteNavigate } from "@/core/route-base";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains"; import { deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain"; import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
@@ -79,6 +80,8 @@ const BRAND =
export default function BusinessChainListPage() { export default function BusinessChainListPage() {
const navigate = useRouteNavigate(); const navigate = useRouteNavigate();
// 整片脱敏:链条列表页展示文本(链条名/描述/席位名/标题)。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。 // 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。 // 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
@@ -233,7 +236,7 @@ export default function BusinessChainListPage() {
}, [chains, search, sortBy]); }, [chains, search, sortBy]);
return ( 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"> <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"> <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, Cpu,
Download, Download,
History, History,
Layers,
ListOrdered, ListOrdered,
Loader2, Loader2,
Pencil, Pencil,
@@ -54,7 +55,9 @@ import {
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
import { useSidebarSafe } from "@/components/ui/sidebar"; import { useSidebarSafe } from "@/components/ui/sidebar";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars"; import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
@@ -98,6 +101,7 @@ import {
} from "@/roundtable-planning/lib/seat-filter"; } from "@/roundtable-planning/lib/seat-filter";
import { import {
buildChainSeatSelection, buildChainSeatSelection,
type BusinessChain,
type SeatSelectionResult, type SeatSelectionResult,
} from "@/roundtable-planning/lib/business-chain"; } from "@/roundtable-planning/lib/business-chain";
import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode"; import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
@@ -110,7 +114,8 @@ import {
startJobKeepalive, startJobKeepalive,
} from "@/roundtable-planning/api/roundtable-jobs"; } from "@/roundtable-planning/api/roundtable-jobs";
import { useRoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier"; import { useRoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
import { getChain } from "@/roundtable-planning/api/chains"; import { getChain, listChains } from "@/roundtable-planning/api/chains";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend"; import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping"; import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
@@ -776,6 +781,34 @@ export default function RoundtablePlanningPage() {
[buildCurrentStep2Snapshot, drafts, mergeActiveRun, step2, step3Report, triggerToast], [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) ────────────────────────────────── // ─── 会商自动化串联(模块 C) ──────────────────────────────────
// 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。 // 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。
useEffect(() => { useEffect(() => {
@@ -1147,6 +1180,8 @@ export default function RoundtablePlanningPage() {
// 真正建好的窗口里,用它让历史记录显示「挂起中」(作业建好后转 queued/running,由后端状态接管)。 // 真正建好的窗口里,用它让历史记录显示「挂起中」(作业建好后转 queued/running,由后端状态接管)。
// resetToStep1 不卸载本页,故该 state 跨「跳转」存活。 // resetToStep1 不卸载本页,故该 state 跨「跳转」存活。
const [pendingSuspendIds, setPendingSuspendIds] = useState<Set<string>>(() => new Set()); const [pendingSuspendIds, setPendingSuspendIds] = useState<Set<string>>(() => new Set());
// 整片脱敏:顶部页头(步骤导航 / 历史记录 / 标题等)的所有文本。
const headerDzRef = useDesensitizeSubtree<HTMLElement>();
const markPendingSuspend = useCallback((draftId: string) => { const markPendingSuspend = useCallback((draftId: string) => {
setPendingSuspendIds((prev) => { setPendingSuspendIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -1446,7 +1481,7 @@ export default function RoundtablePlanningPage() {
)} )}
{/* HEADER */} {/* HEADER */}
<header className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none"> <header ref={headerDzRef} className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
<StepNavigation <StepNavigation
currentStep={currentStep} currentStep={currentStep}
intentReady={step1.intentReady} intentReady={step1.intentReady}
@@ -1557,6 +1592,9 @@ export default function RoundtablePlanningPage() {
onSelectStep2Run={handleSelectStep2Run} onSelectStep2Run={handleSelectStep2Run}
seatFilter={seatFilter} seatFilter={seatFilter}
setSeatFilter={setSeatFilter} setSeatFilter={setSeatFilter}
selectedChainId={autoChainResult?.chain?.id ?? null}
onSelectChain={handleSelectChainFromSidebar}
onOpenDraft={drafts.handleLoadDraft}
/> />
{/* MIDDLE VIEWPORT */} {/* MIDDLE VIEWPORT */}
@@ -2056,6 +2094,12 @@ interface LeftSidebarProps {
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */ /** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
seatFilter: SeatFilter | null; seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<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({ function Step2SandboxSidebarSync({
@@ -2097,7 +2141,13 @@ function LeftSidebar({
onSelectStep2Run, onSelectStep2Run,
seatFilter, seatFilter,
setSeatFilter, setSeatFilter,
selectedChainId,
onSelectChain,
onOpenDraft,
}: LeftSidebarProps) { }: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
const dzRef = useDesensitizeSubtree<HTMLElement>();
const isStep2 = currentStep === 2; const isStep2 = currentStep === 2;
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。 // 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
const toggleSeatFilter = (filter: SeatFilter) => const toggleSeatFilter = (filter: SeatFilter) =>
@@ -2109,6 +2159,7 @@ function LeftSidebar({
return ( return (
<section <section
ref={dzRef}
id="left-sidebar" id="left-sidebar"
className={cn( className={cn(
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out", "shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out",
@@ -2223,28 +2274,147 @@ function LeftSidebar({
</> </>
) )
) : ( ) : (
<> // Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
<TaskOverviewCard currentStep={currentStep} /> <RoundtableEntryRail
selectedChainId={selectedChainId}
<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]"> onSelectChain={onSelectChain}
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3"> onOpenDraft={onOpenDraft}
<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 />
</>
)} )}
</section> </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({ function Step2RunSwitcher({
runs, runs,
activeRunId, activeRunId,
@@ -2869,14 +3039,14 @@ function RoleAvatarBadge({
); );
if (!clickable) { if (!clickable) {
return <div title={`${role.name} · ${role.status}`}>{inner}</div>; return <div title={`${desensitize(role.name)} · ${role.status}`}>{inner}</div>;
} }
return ( return (
<button <button
type="button" type="button"
onClick={onSelect} onClick={onSelect}
aria-pressed={active} 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" className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
> >
{inner} {inner}
@@ -2916,7 +3086,7 @@ function RoleRow({
clickable clickable
? active ? active
? "取消筛选,显示全部发言" ? "取消筛选,显示全部发言"
: `只看 ${role.name} 的发言` : `只看 ${desensitize(role.name)} 的发言`
: undefined : undefined
} }
onClick={onSelect} onClick={onSelect}
@@ -2946,7 +3116,7 @@ function RoleRow({
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" /> <RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
<div className="flex min-w-0 flex-col gap-0.5"> <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"> <span className="truncate text-sm font-bold leading-5 tracking-tight text-slate-800">
{role.name} {desensitize(role.name)}
</span> </span>
{role.type === "agent" && ( {role.type === "agent" && (
<span className="text-[11px] text-primary font-medium leading-4">AI 智能体</span> <span className="text-[11px] text-primary font-medium leading-4">AI 智能体</span>
@@ -3016,10 +3186,13 @@ function RightSidebar({
triggerToast, triggerToast,
onSaveIntent, onSaveIntent,
}: RightSidebarProps) { }: RightSidebarProps) {
// 整片脱敏:右侧面板(推荐模型 / 意图摘要 / 澄清记录)所有文本。
const dzRef = useDesensitizeSubtree<HTMLElement>();
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。 // Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
if (currentStep !== 1) return null; if (currentStep !== 1) return null;
return ( return (
<section <section
ref={dzRef}
id="right-sidebar" id="right-sidebar"
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1" className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
> >
@@ -8,6 +8,7 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
import { useUnreadCount } from "@/core/notifications"; import { useUnreadCount } from "@/core/notifications";
import { useMyPosition } from "@/core/positions";
import { import {
getActiveThirdLevelItems, getActiveThirdLevelItems,
type MenuItem, type MenuItem,
@@ -131,6 +132,8 @@ const Header = ({ collapsed }: HeaderProps) => {
const auth = getStoredAuth(); const auth = getStoredAuth();
const isAdmin = auth?.system_role === "admin"; const isAdmin = auth?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin); const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { data: myPosition } = useMyPosition();
const positionName = myPosition?.name?.trim() || "";
// 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName); // 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName);
// 缺省回退到 stored auth 的邮箱前缀。 // 缺省回退到 stored auth 的邮箱前缀。
@@ -278,6 +281,11 @@ const Header = ({ collapsed }: HeaderProps) => {
<p className="text-sm leading-tight font-medium text-white"> <p className="text-sm leading-tight font-medium text-white">
{displayName} {displayName}
</p> </p>
{positionName && (
<p className="text-[11px] leading-tight text-white/70">
{positionName}
</p>
)}
</div> </div>
<ChevronDown <ChevronDown
className={cn( className={cn(
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more