merge: 合并 master 冲突,保留暗色主题 + 内嵌传参支持
- StudioNotebookDetailPage: 保留 notebookIdProp 内嵌传参 + useTheme 透传 - StudioNotebooksPage: 保留 onOpen 回调 + 暗色主题卡片样式 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
719ff6f210
163 files changed
+11850
-2390
No files matched your search
@@ -84,6 +84,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/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
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
||||
VITE_USE_REAL_DATA=true
|
||||
|
||||
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
|
||||
VITE_MOCK_COP_TASK_DETAIL=false
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
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_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=http://localhost:8003
|
||||
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
# 仅本机 .env 默认 false(假数据)。改后需重启 dev / 重新打包生效。
|
||||
# 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)
|
||||
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
||||
VITE_USE_REAL_DATA=true
|
||||
|
||||
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
|
||||
VITE_MOCK_COP_TASK_DETAIL=false
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
|
||||
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
||||
VITE_USE_REAL_DATA=true
|
||||
|
||||
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
|
||||
VITE_MOCK_COP_TASK_DETAIL=false
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
|
||||
VITE_USE_REAL_DATA=true
|
||||
|
||||
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
|
||||
VITE_MOCK_COP_TASK_DETAIL=false
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
|
||||
|
||||
@@ -86,12 +86,22 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根路由 `/` → `/login` 重定向,**保留 query string**。
|
||||
* 外部系统的任务深链落在 `#/?taskId=...&goPath=...`,裸 `<Navigate to="/login">` 会丢掉
|
||||
* query,导致 LoginPage 读不到参数;这里把 search 一并带过去。
|
||||
*/
|
||||
function RootRedirect() {
|
||||
const { search } = useLocation();
|
||||
return <Navigate to={`/login${search}`} replace />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<AppProviders>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/:username" element={<LoginPage />} />
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
|
||||
@@ -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 { DocumentToolbar } from "./DocumentToolbar";
|
||||
import { FolderSelect } from "./KnowledgeManage";
|
||||
import { FloatingToc } from "./FloatingToc";
|
||||
import { MarkdownViewer } from "./MarkdownViewer";
|
||||
import { RelatedDocs } from "./RelatedDocs";
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
downloadMarkdown,
|
||||
extractToc,
|
||||
formatDateTime,
|
||||
localizeHeadings,
|
||||
noteBreadcrumbSegments,
|
||||
splitRelatedSection,
|
||||
} from "./wikiUtils";
|
||||
@@ -102,10 +104,14 @@ export function WikiContent({
|
||||
|
||||
// Pull the backend-injected `## Related` wikilinks out of the body so they
|
||||
// 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),
|
||||
[note.content_md],
|
||||
);
|
||||
const body = useMemo(() => localizeHeadings(rawBody), [rawBody]);
|
||||
const toc = useMemo(() => extractToc(body), [body]);
|
||||
// The floating outline only shows h2/h3 — deeper levels are noise.
|
||||
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 () => {
|
||||
try {
|
||||
await remove.mutateAsync({ noteId: note.id });
|
||||
@@ -277,6 +292,15 @@ export function WikiContent({
|
||||
{note.created_at ? (
|
||||
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
|
||||
) : 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) => (
|
||||
<button
|
||||
key={tag}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
Copy,
|
||||
FolderTree,
|
||||
Menu,
|
||||
Network,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -48,6 +50,8 @@ export function WikiHeader({
|
||||
onDedup,
|
||||
onGraph,
|
||||
onCreate,
|
||||
onManageFolders,
|
||||
onManageTemplates,
|
||||
}: {
|
||||
onToggleSidebar: () => void;
|
||||
onImportFile: () => void;
|
||||
@@ -55,6 +59,8 @@ export function WikiHeader({
|
||||
onDedup: () => void;
|
||||
onGraph: () => void;
|
||||
onCreate: () => void;
|
||||
onManageFolders: () => void;
|
||||
onManageTemplates: () => void;
|
||||
}) {
|
||||
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">
|
||||
@@ -83,6 +89,16 @@ export function WikiHeader({
|
||||
label="批量导入"
|
||||
onClick={onBatchImport}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon={<FolderTree className="size-4" />}
|
||||
label="目录管理"
|
||||
onClick={onManageFolders}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon={<SlidersHorizontal className="size-4" />}
|
||||
label="抽取模板"
|
||||
onClick={onManageTemplates}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon={<Copy className="size-4" />}
|
||||
label="查重"
|
||||
|
||||
@@ -14,4 +14,6 @@ export interface TreeNode {
|
||||
type: "folder" | "file";
|
||||
children?: TreeNode[];
|
||||
item?: KnowledgeNote;
|
||||
/** Full slash-joined directory path (folder nodes only). */
|
||||
folderPath?: string;
|
||||
}
|
||||
@@ -19,6 +19,73 @@ export const STATUS_LABELS: Record<string, string> = {
|
||||
|
||||
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. */
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
@@ -119,16 +186,17 @@ export function splitRelatedSection(markdown: string): {
|
||||
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[] {
|
||||
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (rawPath && rawPath.includes("/")) {
|
||||
let segments = rawPath.split("/").slice(0, -1).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);
|
||||
}
|
||||
const folder = (note.folder || "").replace(/^\/+|\/+$/g, "").trim();
|
||||
if (folder) {
|
||||
const segments = folder.split("/").map((s) => s.trim()).filter(Boolean);
|
||||
if (segments.length > 0) return segments;
|
||||
}
|
||||
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). */
|
||||
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
|
||||
return ["问答梳理", ...noteFolderSegments(note)];
|
||||
return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
|
||||
}
|
||||
|
||||
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
|
||||
* 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 folderMap = new Map<string, TreeNode>();
|
||||
|
||||
@@ -164,7 +235,9 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
|
||||
path = path ? `${path}/${segment}` : segment;
|
||||
let node = folderMap.get(path);
|
||||
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);
|
||||
children.push(node);
|
||||
}
|
||||
@@ -173,6 +246,13 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
|
||||
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) {
|
||||
ensureFolder(noteFolderSegments(note)).push({
|
||||
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 TagType,
|
||||
} from "@/core/tags";
|
||||
import { TAG_TYPES, TAG_TYPE_LABELS } from "@/core/tags/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TagFormDialog({
|
||||
@@ -191,11 +192,11 @@ export function TagManagementPage() {
|
||||
onValueChange={(v) => setActiveType(v as TagType)}
|
||||
>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
|
||||
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
|
||||
<TabsTrigger value="scheduled_task">
|
||||
{t.adminTags.typeScheduledTask}
|
||||
</TabsTrigger>
|
||||
{TAG_TYPES.map((tt) => (
|
||||
<TabsTrigger key={tt} value={tt}>
|
||||
{TAG_TYPE_LABELS[tt]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
EyeOffIcon,
|
||||
GlobeIcon,
|
||||
@@ -78,6 +79,7 @@ import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -605,6 +607,17 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
</button>
|
||||
) : 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 只存英文名)。
|
||||
const skillNameZhMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -820,6 +833,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
</CardDescription>
|
||||
)}
|
||||
{publisherNode && <div className="mt-2">{publisherNode}</div>}
|
||||
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
@@ -866,6 +880,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
</p>
|
||||
)}
|
||||
{publisherNode && <div className="mt-0.5">{publisherNode}</div>}
|
||||
{createdAtNode && <div className="mt-0.5">{createdAtNode}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showActionRow && actionButtons}
|
||||
|
||||
@@ -187,23 +187,30 @@ export function AgentDetailPanel({
|
||||
const isSaving = updateAgent.isPending || setFeatured.isPending;
|
||||
|
||||
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
|
||||
// 信息面板给预览让位;新文件落地时同样收起。编辑中不打断用户,用户随时
|
||||
// 信息面板给预览让位;切换到新文件时同样收起。编辑中不打断用户,用户随时
|
||||
// 可手动重新展开。
|
||||
//
|
||||
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
|
||||
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
|
||||
// 该文件要等生成结束、artifact 入库后才会进 thread.values.artifacts。若按
|
||||
// 数量判定就只能等生成结束才收起——正是之前「生成完才收起」的 bug 根因。
|
||||
const artifactsCtx = useArtifactsOptional();
|
||||
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 prevArtifactCount = useRef(0);
|
||||
const prevSelectedArtifact = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
|
||||
const grew =
|
||||
artifactPanelVisible && artifactCount > prevArtifactCount.current;
|
||||
const switched =
|
||||
artifactPanelVisible && selectedArtifact !== prevSelectedArtifact.current;
|
||||
prevArtifactPanelVisible.current = artifactPanelVisible;
|
||||
prevArtifactCount.current = artifactCount;
|
||||
if ((opened || grew) && !editing) {
|
||||
prevSelectedArtifact.current = selectedArtifact;
|
||||
if ((opened || switched) && !editing) {
|
||||
setCollapsed(true);
|
||||
}
|
||||
}, [artifactPanelVisible, artifactCount, editing]);
|
||||
}, [artifactPanelVisible, selectedArtifact, editing]);
|
||||
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
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";
|
||||
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
|
||||
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentSelectorProps {
|
||||
@@ -51,12 +52,21 @@ export function AgentSelector({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const { agents, isLoading } = useAgentSelector();
|
||||
// 弹窗列表只展示管理弹窗里配置的 agent(agents);但「已选中 agent」的名称解析要更宽——
|
||||
// 侧边栏「常用智能体」可以选中未配置进管理弹窗的 agent(如用户自己的智能体),
|
||||
// 这些 agent 也要能在胶囊上显示名称,故额外用「我的 / 广场」列表兜底解析(缓存与侧边栏共享)。
|
||||
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
|
||||
|
||||
const selectedAgent = useMemo<Agent | null>(() => {
|
||||
if (!selectedAgentId) return null;
|
||||
return agents.find((a) => a.id === selectedAgentId) ?? null;
|
||||
}, [agents, selectedAgentId]);
|
||||
return (
|
||||
[...agents, ...mineAgents, ...squareAgents].find(
|
||||
(a) => a.id === selectedAgentId,
|
||||
) ?? null
|
||||
);
|
||||
}, [agents, mineAgents, squareAgents, selectedAgentId]);
|
||||
|
||||
const handleSelect = (agent: Agent) => {
|
||||
onAgentChange(agent.id);
|
||||
@@ -68,7 +78,7 @@ export function AgentSelector({
|
||||
onAgentChange(null);
|
||||
};
|
||||
|
||||
const triggerLabel = selectedAgent?.name ?? "选择 Agent";
|
||||
const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -125,7 +135,7 @@ export function AgentSelector({
|
||||
{agents.map((agent) => (
|
||||
<CommandItem
|
||||
key={agent.id}
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
value={`${desensitize(agent.name)} ${desensitize(agent.description ?? "")}`}
|
||||
onSelect={() => handleSelect(agent)}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-start gap-2 overflow-hidden",
|
||||
@@ -134,10 +144,10 @@ export function AgentSelector({
|
||||
>
|
||||
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
<span className="truncate text-sm">{desensitize(agent.name)}</span>
|
||||
{agent.description && (
|
||||
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
|
||||
{agent.description}
|
||||
{desensitize(agent.description)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -122,6 +122,7 @@ export function ArtifactFileDetail({
|
||||
threadId,
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
}: {
|
||||
className?: string;
|
||||
filepath: string;
|
||||
@@ -134,6 +135,12 @@ export function ArtifactFileDetail({
|
||||
* 默认 false —— 主聊天页等其它调用方行为不变。
|
||||
*/
|
||||
allowVirtualMarkdownEdit?: boolean;
|
||||
/**
|
||||
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
|
||||
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
|
||||
* 默认 false,沿用全局脱敏。
|
||||
*/
|
||||
disableDesensitize?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
@@ -802,7 +809,7 @@ export function ArtifactFileDetail({
|
||||
language === "html" ||
|
||||
language === "mermaid") && (
|
||||
<ArtifactFilePreview
|
||||
content={desensitize(displayContent ?? "")}
|
||||
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
|
||||
language={language ?? "text"}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
@@ -815,7 +822,7 @@ export function ArtifactFileDetail({
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={
|
||||
canEditArtifact
|
||||
canEditArtifact || disableDesensitize
|
||||
? (displayContent ?? "")
|
||||
: desensitize(displayContent ?? "")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,7 @@ import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -94,6 +95,7 @@ export function MessageList({
|
||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
tokenUsageEnabled = false,
|
||||
extraRehypePlugins,
|
||||
scrollButtonClassName,
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onSetupAgentApprove,
|
||||
@@ -108,6 +110,8 @@ export function MessageList({
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
paddingBottom?: number;
|
||||
tokenUsageEnabled?: boolean;
|
||||
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
||||
scrollButtonClassName?: string;
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
@@ -577,6 +581,16 @@ export function MessageList({
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</MessageMetricsProvider>
|
||||
</ConversationContent>
|
||||
{/* ChatGPT 式「回到最新」悬浮钮:仅在向上滚离底部时出现,点击平滑回到
|
||||
最新问答。默认贴底,调用方可用 scrollButtonClassName 上移避开输入框。 */}
|
||||
<ConversationScrollButton
|
||||
className={cn(
|
||||
"pointer-events-auto z-20 size-9 shadow-md",
|
||||
scrollButtonClassName,
|
||||
)}
|
||||
aria-label="回到最新"
|
||||
title="回到最新"
|
||||
/>
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { cn } from "@/lib/utils";
|
||||
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
||||
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
||||
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
|
||||
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
@@ -457,14 +457,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<RecommendedQuestionsPage embedded />
|
||||
<Separator />
|
||||
<PromptPrefixSettingsPage embedded />
|
||||
<Separator />
|
||||
<PrivateSquareSettingsPage embedded />
|
||||
<Separator />
|
||||
<PublishSquaresSettingsPage embedded />
|
||||
<Separator />
|
||||
<SkillCompressionSettingsPage embedded />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Anchor,
|
||||
ArchiveIcon,
|
||||
ArrowDownUpIcon,
|
||||
BookmarkIcon,
|
||||
@@ -77,6 +78,7 @@ import {
|
||||
useGenerateSkillNameZh,
|
||||
useInstallSkillUpload,
|
||||
useReconcileCustomSkills,
|
||||
useSetSkillAlwaysOn,
|
||||
useSetSkillFeatured,
|
||||
useSetSkillNameZh,
|
||||
useSetSkillPinned,
|
||||
@@ -84,8 +86,13 @@ import {
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} 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 { formatDateTime } from "@/core/utils/datetime";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -175,13 +182,22 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||
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 currentUserId = storedAuth?.user_id ?? null;
|
||||
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 关心的几个桶。
|
||||
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
||||
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
||||
@@ -209,7 +225,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
if (isBuiltin || !!skill.published) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -593,6 +611,8 @@ export function SkillRow({
|
||||
const { data: cardPublishSquares } = usePublishSquares();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const setFeatured = useSetSkillFeatured();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const duplicateSkillMutation = useDuplicateSkill();
|
||||
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.
|
||||
const canManage = isCustom && (isOwned || isAdmin);
|
||||
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) {
|
||||
// Publishing: prompt for which square to publish into (when configured).
|
||||
@@ -806,6 +837,15 @@ export function SkillRow({
|
||||
{t.settings.skills.badgePinned}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.always_on && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
|
||||
title="常驻(免压缩):开启技能压缩后,本技能仍全文注入系统提示词并被正常检索"
|
||||
>
|
||||
常驻
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -848,6 +888,14 @@ export function SkillRow({
|
||||
: t.settings.skills.useCountInlineZero}
|
||||
</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 && (
|
||||
<TagChipList tags={skill.tags!} className="mt-2" />
|
||||
)}
|
||||
@@ -907,6 +955,27 @@ export function SkillRow({
|
||||
/>
|
||||
</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 && (
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
@@ -37,12 +38,14 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useGenerateSkillNameZh,
|
||||
useSetSkillAlwaysOn,
|
||||
useSetSkillDetail,
|
||||
useSetSkillNameZh,
|
||||
useSkill,
|
||||
useSkillContent,
|
||||
useSkillFiles,
|
||||
} from "@/core/skills/hooks";
|
||||
import { useSkillCompressionSettings } from "@/core/system-settings/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -105,6 +108,8 @@ export function SkillDetailDialog({
|
||||
const setDetail = useSetSkillDetail();
|
||||
const setNameZh = useSetSkillNameZh();
|
||||
const generateNameZh = useGenerateSkillNameZh();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -327,6 +332,15 @@ export function SkillDetailDialog({
|
||||
{tx.badgePublished}
|
||||
</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>
|
||||
)}
|
||||
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
|
||||
@@ -372,6 +386,31 @@ export function SkillDetailDialog({
|
||||
</TabsList>
|
||||
|
||||
<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 && (
|
||||
<Button
|
||||
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,
|
||||
BotIcon,
|
||||
BrainIcon,
|
||||
BriefcaseIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
@@ -216,6 +218,26 @@ export function WorkspaceNavMenu() {
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</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 && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { FrequentAgentsList } from "./frequent-agents-list";
|
||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
@@ -30,7 +30,8 @@ export function WorkspaceSidebar({
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;其余 workspace 页保持原样。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
@@ -58,13 +59,16 @@ export function WorkspaceSidebar({
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
{isGeneralQa && <FrequentAgentsList />}
|
||||
<RecentChatList
|
||||
compact
|
||||
limit={isGeneralQa ? 10 : undefined}
|
||||
moreHref={isGeneralQa ? "/page/workspace/chats" : undefined}
|
||||
/>
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
@@ -0,0 +1,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` };
|
||||
}
|
||||
@@ -4,8 +4,11 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import type {
|
||||
BatchIngestPayload,
|
||||
BatchIngestResult,
|
||||
ExtractTemplateScope,
|
||||
GraphExportResult,
|
||||
IngestThreadPayload,
|
||||
KnowledgeExtractTemplate,
|
||||
KnowledgeFolder,
|
||||
KnowledgeListParams,
|
||||
KnowledgeListResponse,
|
||||
KnowledgeNote,
|
||||
@@ -35,6 +38,7 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
|
||||
if (params.entity) qs.set("entity", params.entity);
|
||||
if (params.source_type) qs.set("source_type", params.source_type);
|
||||
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("offset", String(params.offset ?? 0));
|
||||
return parseResponse<KnowledgeListResponse>(
|
||||
@@ -54,6 +58,7 @@ export async function createKnowledgeNote(data: {
|
||||
summary?: string | null;
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
folder?: string | null;
|
||||
}) {
|
||||
const res = await parseResponse<{ note: KnowledgeNote }>(
|
||||
await apiFetch(`${baseUrl()}/notes`, {
|
||||
@@ -73,6 +78,7 @@ export async function updateKnowledgeNote(
|
||||
summary: string | null;
|
||||
tags: string[];
|
||||
status: string;
|
||||
folder: string | null;
|
||||
}>,
|
||||
) {
|
||||
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). */
|
||||
export async function ingestKnowledgeFile(
|
||||
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();
|
||||
form.append("file", file);
|
||||
@@ -146,6 +159,8 @@ export async function ingestKnowledgeFile(
|
||||
if (opts.tags) form.append("tags", opts.tags);
|
||||
if (opts.status) form.append("status", opts.status);
|
||||
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.
|
||||
return parseResponse<IngestFileResult>(
|
||||
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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
|
||||
import {
|
||||
addGraphBlacklist,
|
||||
createExtractTemplate,
|
||||
createKnowledgeFolder,
|
||||
createKnowledgeNote,
|
||||
deleteExtractTemplate,
|
||||
deleteKnowledgeFolder,
|
||||
deleteKnowledgeNote,
|
||||
exportKnowledgeGraph,
|
||||
fetchVaultPage,
|
||||
@@ -18,14 +22,18 @@ import {
|
||||
ingestThread,
|
||||
ingestThreadsBatch,
|
||||
listDuplicates,
|
||||
listExtractTemplates,
|
||||
listKnowledgeFolders,
|
||||
listKnowledgeNotes,
|
||||
mergeKnowledgeNotes,
|
||||
removeGraphBlacklist,
|
||||
renameKnowledgeFolder,
|
||||
resolveNoteWikilinks,
|
||||
searchKnowledge,
|
||||
updateExtractTemplate,
|
||||
updateKnowledgeNote,
|
||||
} from "./api";
|
||||
import type { KnowledgeListParams } from "./types";
|
||||
import type { ExtractTemplateScope, KnowledgeListParams } from "./types";
|
||||
|
||||
const ROOT_KEY = ["knowledge"] as const;
|
||||
|
||||
@@ -172,3 +180,62 @@ export function useKnowledgeDuplicates(enabled: boolean) {
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export interface KnowledgeNote {
|
||||
summary?: string | null;
|
||||
content_md: string;
|
||||
vault_path?: string | null;
|
||||
/** User-assigned directory (slash-joined path); null/undefined = unfiled. */
|
||||
folder?: string | null;
|
||||
source_type: string;
|
||||
source_id?: string | null;
|
||||
status: KnowledgeStatus;
|
||||
@@ -58,10 +60,37 @@ export interface KnowledgeListParams {
|
||||
entity?: string;
|
||||
source_type?: string;
|
||||
status?: string;
|
||||
/** Filter to a directory (and its sub-folders); "" = unfiled only. */
|
||||
folder?: string;
|
||||
limit?: 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 interface WikilinkResolution {
|
||||
@@ -105,6 +134,10 @@ export interface IngestThreadPayload {
|
||||
background?: boolean;
|
||||
/** Optional model override for knowledge extraction (null = configured default). */
|
||||
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 {
|
||||
@@ -114,12 +147,25 @@ export interface BatchIngestPayload {
|
||||
include_sources?: boolean;
|
||||
skip_existing?: boolean;
|
||||
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). */
|
||||
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 {
|
||||
ok: boolean;
|
||||
/** True when the backend queued the batch as a background task (see payload). */
|
||||
queued?: boolean;
|
||||
processed: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
Briefcase,
|
||||
CalendarClock,
|
||||
CircleHelp,
|
||||
ClipboardList,
|
||||
@@ -52,7 +53,7 @@ export type MenuItem = {
|
||||
export const sidebarMenuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "问答中心",
|
||||
label: "问答管理",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
@@ -65,11 +66,11 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
|
||||
{
|
||||
id: "qa-zzjc",
|
||||
label: "zzJC支持",
|
||||
label: "JC支持",
|
||||
icon: Bot,
|
||||
path: "/page/strategy/agents/6bf/chats/new",
|
||||
path: "/page/strategy/qa/zzjc",
|
||||
},
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", },
|
||||
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
],
|
||||
},
|
||||
@@ -102,24 +103,24 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
label: "COH中任务",
|
||||
icon: Activity,
|
||||
children: [
|
||||
{
|
||||
id: "coh-task-3qfx",
|
||||
label: "3QFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task-sentiment",
|
||||
},
|
||||
{
|
||||
id: "coh-task-fx",
|
||||
label: "任务FX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-fx",
|
||||
},
|
||||
{
|
||||
id: "coh-task-xdfx",
|
||||
label: "XDFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-action",
|
||||
},
|
||||
// {
|
||||
// id: "coh-task-3qfx",
|
||||
// label: "3QFX",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/plan-task-sentiment",
|
||||
// },
|
||||
// {
|
||||
// id: "coh-task-fx",
|
||||
// label: "任务FX",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/plan-task/plan-task-fx",
|
||||
// },
|
||||
// {
|
||||
// id: "coh-task-xdfx",
|
||||
// label: "XDFX",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/plan-task/plan-task-action",
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -241,6 +242,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
path: "/page/strategy/admin/users",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-positions",
|
||||
label: "岗位管理",
|
||||
icon: Briefcase,
|
||||
path: "/page/strategy/admin/positions",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-admin-scheduled-tasks",
|
||||
label: "定时任务管理",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./api";
|
||||
export * from "./hooks";
|
||||
@@ -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`;
|
||||
|
||||
export async function listRecommendedQuestions(): Promise<RecommendedQuestion[]> {
|
||||
const res = await apiFetch(BASE());
|
||||
export async function listRecommendedQuestions(all = false): Promise<RecommendedQuestion[]> {
|
||||
// ``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}`);
|
||||
const data = (await res.json()) as { questions: RecommendedQuestion[] };
|
||||
return data.questions;
|
||||
|
||||
@@ -10,11 +10,21 @@ import {
|
||||
import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index";
|
||||
|
||||
const QK = ["recommended-questions"] as const;
|
||||
const ADMIN_QK = ["recommended-questions", "admin"] as const;
|
||||
|
||||
export function useRecommendedQuestions() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
export interface RecommendedQuestion {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
sort_order: number;
|
||||
/** Tags assigned to this question (按岗位分发标签). */
|
||||
tags?: Tag[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
contextCompressionEnabled: false,
|
||||
citationCleanupWords: [],
|
||||
fontScale: 1,
|
||||
},
|
||||
@@ -43,6 +44,12 @@ export interface LocalSettings {
|
||||
* messages render tool results with the legacy generic search UI only.
|
||||
*/
|
||||
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
|
||||
* and dialogs, one entry per line in the settings UI.
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface ListSkillsParams {
|
||||
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 (发布广场). */
|
||||
square?: string;
|
||||
/** ``"mine"`` restricts to the skills the caller's 岗位 (position) makes visible. */
|
||||
scope?: "mine";
|
||||
}
|
||||
|
||||
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);
|
||||
if (params.sort) qs.set("sort", params.sort);
|
||||
if (params.square) qs.set("square", params.square);
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
const query = qs.toString();
|
||||
const skills = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||
@@ -62,6 +65,26 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
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(
|
||||
skillName: string,
|
||||
display: SkillDisplayConfig | null,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
installSkillUpload,
|
||||
reconcileCustomSkills,
|
||||
saveSkillFile,
|
||||
setSkillAlwaysOn,
|
||||
setSkillDetail,
|
||||
setSkillNameZh,
|
||||
setSkillDisplay,
|
||||
@@ -42,9 +43,10 @@ export function useSkills(params: ListSkillsParams = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const scope = params.scope;
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", search, [...tagIds].sort(), sort],
|
||||
queryFn: () => loadSkills({ search, tagIds, sort }),
|
||||
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
|
||||
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
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() {
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => validateSkillUpload(file),
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface Skill {
|
||||
category: string;
|
||||
license: string;
|
||||
enabled: boolean;
|
||||
// 常驻(免压缩) —— 开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,
|
||||
// 而非被压缩成索引 + search_skills 检索。
|
||||
always_on?: boolean;
|
||||
// Multitenancy fields — owner_user_id is null for built-in / public skills.
|
||||
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
|
||||
owner_user_id?: string | null;
|
||||
@@ -58,6 +61,9 @@ export interface Skill {
|
||||
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
|
||||
square_id?: string;
|
||||
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.
|
||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||
detail?: string | null;
|
||||
|
||||
@@ -247,6 +247,89 @@ export async function updateCitationDisplaySettings(
|
||||
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 {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
|
||||
@@ -3,24 +3,29 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
|
||||
import {
|
||||
loadAgentModuleConfig,
|
||||
loadAppearanceSettings,
|
||||
loadCitationDisplaySettings,
|
||||
loadLeaderboardSettings,
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
loadPublishSquares,
|
||||
loadSkillCompressionSettings,
|
||||
updateAgentModuleConfig,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updateLeaderboardSettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
updatePublishSquares,
|
||||
updateSkillCompressionSettings,
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type LeaderboardSettingsUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
type PublishSquaresUpdate,
|
||||
type SkillCompressionUpdate,
|
||||
type LayoutMode,
|
||||
type ViewMode,
|
||||
} from "./api";
|
||||
@@ -31,6 +36,7 @@ const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
|
||||
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
||||
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
@@ -165,6 +171,29 @@ export function useLayoutMode(): [
|
||||
return [mode, setMode];
|
||||
}
|
||||
|
||||
/** 「按类型聚合」模块(zzJC / zzXD)选中标签配置(需求 0616 #3/#4)。 */
|
||||
const agentModuleKey = (key: string) =>
|
||||
["systemSettings", "agentModule", key] as const;
|
||||
|
||||
export function useAgentModuleConfig(key: string) {
|
||||
return useQuery({
|
||||
queryKey: agentModuleKey(key),
|
||||
queryFn: () => loadAgentModuleConfig(key),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAgentModuleConfig(key: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (tagIds: string[]) => updateAgentModuleConfig(key, tagIds),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(agentModuleKey(key), data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCitationDisplaySettings() {
|
||||
return useQuery({
|
||||
queryKey: CITATION_DISPLAY_KEY,
|
||||
@@ -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() {
|
||||
return useQuery({
|
||||
queryKey: LEADERBOARD_KEY,
|
||||
|
||||
@@ -83,6 +83,7 @@ export function useSetResourceTags() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["recommended-questions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
id: string;
|
||||
|
||||
@@ -343,6 +343,23 @@ export function useThreadStream({
|
||||
) {
|
||||
const e = event as { type: "llm_retry"; message: string };
|
||||
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) {
|
||||
@@ -647,6 +664,9 @@ export function useThreadStream({
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
const contextCompressionEnabled =
|
||||
localSettings.display.contextCompressionEnabled ?? false;
|
||||
|
||||
const mergedContext = {
|
||||
...context,
|
||||
...extraContext,
|
||||
@@ -698,6 +718,7 @@ export function useThreadStream({
|
||||
memory_injection_enabled:
|
||||
mergedContext.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
summarization_enabled: contextCompressionEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
writing_artifact_path?: string;
|
||||
memory_injection_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). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
|
||||
@@ -25,3 +25,18 @@ export function formatTimeAgo(date: Date | string | number, locale?: Locale) {
|
||||
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())}`
|
||||
);
|
||||
}
|
||||
@@ -100,9 +100,20 @@ function candidateTextFromHref(href: string): string {
|
||||
|
||||
/**
|
||||
* Injects a small runtime script into generated HTML so that clicking an
|
||||
* in-page anchor (`<a href="#section">`) scrolls within the iframe instead of
|
||||
* navigating it. The preview iframe loads from a sandboxed `blob:` URL, where a
|
||||
* fragment navigation blanks the frame; intercepting the click avoids that.
|
||||
* in-page anchor (`<a href="#section">`) works inside the preview iframe.
|
||||
*
|
||||
* 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 {
|
||||
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,"\\\\$&");
|
||||
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){
|
||||
var el=e.target;
|
||||
while(el&&el.tagName!=="A"){el=el.parentElement;}
|
||||
@@ -119,16 +146,10 @@ export function injectInPageAnchorHandler(html: string): string {
|
||||
var href=el.getAttribute("href");
|
||||
if(!href||href.charAt(0)!=="#"||href.length<2)return;
|
||||
e.preventDefault();
|
||||
var id=decode(href.slice(1));
|
||||
var raw=href.slice(1);
|
||||
var target=
|
||||
document.getElementById(id)||
|
||||
document.getElementById(raw)||
|
||||
document.getElementsByName(id)[0]||
|
||||
document.getElementsByName(raw)[0]||
|
||||
bySelector(id)||
|
||||
bySelector(raw);
|
||||
if(target){target.scrollIntoView({behavior:"smooth",block:"start"});}
|
||||
var target=find(href.slice(1));
|
||||
if(!target)return;
|
||||
if(target.classList&&target.classList.contains("page")&&switchPage(target))return;
|
||||
target.scrollIntoView({behavior:"smooth",block:"start"});
|
||||
},true);
|
||||
})();</script>`;
|
||||
if (/<\/body>/i.test(html)) {
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* value: 脱敏后替换词
|
||||
*/
|
||||
export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
方案: "FA",
|
||||
设计: "SJ",
|
||||
任务: "RW",
|
||||
态势: "TS",
|
||||
滨海作战体系: "第一张网",
|
||||
滨海作战部队: "第一张网",
|
||||
@@ -72,6 +75,26 @@ export function desensitize(text: string): string {
|
||||
return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度脱敏:递归遍历对象 / 数组,对其中所有字符串值做 {@link desensitize},
|
||||
* 其余类型原样保留。返回**新值**(不修改入参),用于对结构化数据(如大屏 report-json
|
||||
* 解析出的对象)整体脱敏后再渲染。
|
||||
*
|
||||
* 注意:仅用于「展示」路径——不要用它改写要回传后端 / 落库的数据。
|
||||
*/
|
||||
export function desensitizeDeep<T>(value: T): T {
|
||||
if (typeof value === "string") return desensitize(value) as unknown as T;
|
||||
if (Array.isArray(value)) return value.map((v) => desensitizeDeep(v)) as unknown as T;
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = desensitizeDeep(v);
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时动态追加脱敏词,追加后自动重编译正则。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
/** 课题研究模板:一组写作表单预设(需求 0616 #5)。 */
|
||||
export interface ResearchTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
/** 引用的文章类型 key(对应 ArticleType.key);null = 不设。 */
|
||||
article_type_key: string | null;
|
||||
outline: string | null;
|
||||
material_source: 'general' | 'notebook';
|
||||
word_count: number;
|
||||
audience: string | null;
|
||||
strict_mode: boolean;
|
||||
topic: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateResearchTemplateRequest {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
article_type_key?: string | null;
|
||||
outline?: string | null;
|
||||
material_source?: 'general' | 'notebook';
|
||||
word_count?: number;
|
||||
audience?: string | null;
|
||||
strict_mode?: boolean;
|
||||
topic?: string | null;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export type UpdateResearchTemplateRequest = Partial<CreateResearchTemplateRequest>;
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/research-templates`;
|
||||
|
||||
export async function listResearchTemplates(): Promise<ResearchTemplate[]> {
|
||||
const resp = await apiFetch(BASE());
|
||||
if (!resp.ok) throw new Error(`获取研究模板失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function createResearchTemplate(
|
||||
data: CreateResearchTemplateRequest,
|
||||
): Promise<ResearchTemplate> {
|
||||
const resp = await apiFetch(BASE(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`创建研究模板失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function updateResearchTemplate(
|
||||
id: string,
|
||||
data: UpdateResearchTemplateRequest,
|
||||
): Promise<ResearchTemplate> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (resp.status === 404) throw new Error(`研究模板不存在`);
|
||||
if (!resp.ok) throw new Error(`更新研究模板失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function deleteResearchTemplate(id: string): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
|
||||
if (resp.status === 404) throw new Error(`研究模板不存在`);
|
||||
if (!resp.ok) throw new Error(`删除研究模板失败: ${resp.status}`);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { PenLine, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -55,8 +55,6 @@ export function AIWritingForm({
|
||||
onSubmit,
|
||||
disabled,
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: AIWritingFormProps) {
|
||||
const {
|
||||
@@ -68,13 +66,12 @@ export function AIWritingForm({
|
||||
wordCountInput, setWordCountInput,
|
||||
strictMode, setStrictMode,
|
||||
materialSource, setMaterialSource,
|
||||
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
|
||||
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
|
||||
selectedNotebookId, setSelectedNotebookId,
|
||||
selectedDocumentIds, setSelectedDocumentIds,
|
||||
modelName, setModelName,
|
||||
} = useWritingForm();
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(
|
||||
initialNotebookId ?? null,
|
||||
);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
||||
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
|
||||
@@ -146,8 +143,10 @@ export function AIWritingForm({
|
||||
}
|
||||
|
||||
function toggleDocument(docId: number) {
|
||||
setSelectedDocumentIds((prev) =>
|
||||
prev.includes(docId) ? prev.filter((id) => id !== docId) : [...prev, docId],
|
||||
setSelectedDocumentIds(
|
||||
selectedDocumentIds.includes(docId)
|
||||
? selectedDocumentIds.filter((id) => id !== docId)
|
||||
: [...selectedDocumentIds, docId],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, FileText, LayoutGrid, Loader2, Plus, Settings2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { createSearchSpace } from "@/core/studio/api";
|
||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useResearchTemplates } from "../../hooks/useResearchTemplates";
|
||||
import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
|
||||
import { useWritingForm } from "../../contexts/WritingFormContext";
|
||||
import { useAIWriting } from "../../contexts/AIWritingContext";
|
||||
|
||||
/**
|
||||
* 课题研究左栏(需求 0616 #5)。
|
||||
* - 上半「研究模板」= 研究模板实体(``useResearchTemplates``);点击一键预填整张写作表单
|
||||
* (文章类型 / 大纲 / 搜索来源 / 字数 / 受众 / 严格模式 / 主题)并展开配置。顶部「配置」
|
||||
* 入口进入模板配置页维护这些模板。
|
||||
* - 下半「笔记本」= 我的空间(``useStudioNotebooks``);点击在右侧内嵌打开该笔记本页。
|
||||
*/
|
||||
export function ResearchSidebar({
|
||||
viewingNotebookId,
|
||||
onOpenNotebook,
|
||||
onOpenAllSpaces,
|
||||
}: {
|
||||
/** 当前在右侧查看的笔记本 id(高亮用)。 */
|
||||
viewingNotebookId?: number | null;
|
||||
/** 点击笔记本 → 在右侧内嵌打开该笔记本页(替换右侧内容)。 */
|
||||
onOpenNotebook: (notebookId: number) => void;
|
||||
/** 点击「所有空间」→ 在右侧内嵌打开空间列表页。 */
|
||||
onOpenAllSpaces: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { templates, refresh } = useResearchTemplates();
|
||||
const { startWriting } = useAIWriting();
|
||||
const {
|
||||
notebooks,
|
||||
isLoading: notebooksLoading,
|
||||
isError: notebooksError,
|
||||
} = useStudioNotebooks();
|
||||
const {
|
||||
setIntent,
|
||||
setArticleType,
|
||||
setOutlineText,
|
||||
setShowOutline,
|
||||
setMaterialSource,
|
||||
setWordCountInput,
|
||||
setAudience,
|
||||
setStrictMode,
|
||||
setFormExpanded,
|
||||
} = useWritingForm();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 新建空间弹窗(与 workspace-nav-studio 同款)。
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setCreating(true);
|
||||
setCreateError("");
|
||||
try {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
const token = queryClient.getQueryData<string>(["studio", "token", email]);
|
||||
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
|
||||
await createSearchSpace(trimmed, token);
|
||||
setCreateOpen(false);
|
||||
setName("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : "创建失败");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击研究模板 → ①回填整张写作表单(保持写作态左栏显示与运行配置一致)
|
||||
// ②直接以模板预设进入 AI 写作流程(startWriting)。
|
||||
const handleTemplateClick = (tpl: ResearchTemplate) => {
|
||||
const intent = tpl.topic?.trim() ?? "";
|
||||
const articleType = tpl.article_type_key || "analysis";
|
||||
const outline = tpl.outline?.trim() || "";
|
||||
const audience = tpl.audience?.trim() || "普通读者";
|
||||
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
|
||||
const writingMode = tpl.strict_mode ? "strict" : "loose";
|
||||
|
||||
// 回填表单上下文(写作态左栏据此展示)。
|
||||
if (intent) setIntent(intent);
|
||||
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
|
||||
if (outline) {
|
||||
setOutlineText(outline);
|
||||
setShowOutline(true);
|
||||
}
|
||||
setMaterialSource(tpl.material_source);
|
||||
setWordCountInput(String(wordCount));
|
||||
if (tpl.audience?.trim()) setAudience(audience);
|
||||
setStrictMode(tpl.strict_mode);
|
||||
setFormExpanded(true);
|
||||
|
||||
// 无主题/意图无法发起写作 → 仅回填表单,由用户补充后手动开始。
|
||||
if (!intent) return;
|
||||
void startWriting({
|
||||
userIntent: intent,
|
||||
userOutline: outline || undefined,
|
||||
articleType,
|
||||
targetAudience: audience,
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: 1,
|
||||
writingMode,
|
||||
materialSource: tpl.material_source,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-64 shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
|
||||
{/* 上半:研究模板 */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
|
||||
<div className="px-2 mb-1.5 flex items-center justify-between">
|
||||
<h2 className="text-muted-foreground/70 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
研究模板
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/canvas/research-templates")}
|
||||
title="配置研究模板"
|
||||
aria-label="配置研究模板"
|
||||
className="text-muted-foreground/70 hover:text-foreground rounded p-0.5 transition-colors"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{templates.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">
|
||||
暂无研究模板,点右上角配置
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{templates.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplateClick(tpl)}
|
||||
title={tpl.description ?? tpl.name}
|
||||
className="flex items-start gap-2 rounded-lg px-2.5 py-1.5 text-left text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
|
||||
>
|
||||
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-medium">{tpl.name}</span>
|
||||
{tpl.description && (
|
||||
<span className="text-muted-foreground/70 line-clamp-1 block text-[11px]">
|
||||
{tpl.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下半:笔记本(我的空间) */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
|
||||
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
笔记本
|
||||
</h2>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* 新建空间 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setName("");
|
||||
setCreateError("");
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span>新建空间</span>
|
||||
</button>
|
||||
|
||||
{notebooksLoading ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">加载中…</p>
|
||||
) : notebooksError ? (
|
||||
<p className="px-2 py-1.5 text-xs text-amber-600">
|
||||
笔记本服务暂不可用(检查空间服务地址 / 账号)
|
||||
</p>
|
||||
) : notebooks.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无笔记本</p>
|
||||
) : (
|
||||
notebooks.map((nb) => {
|
||||
const active = viewingNotebookId === nb.id;
|
||||
return (
|
||||
<button
|
||||
key={nb.id}
|
||||
type="button"
|
||||
onClick={() => onOpenNotebook(nb.id)}
|
||||
title={nb.description || nb.name}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">{nb.name}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 所有空间 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAllSpaces}
|
||||
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
|
||||
>
|
||||
<LayoutGrid className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span>所有空间</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新建空间弹窗 */}
|
||||
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCreate();
|
||||
}}
|
||||
placeholder="空间名称"
|
||||
disabled={creating}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={creating}
|
||||
onClick={() => setCreateOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || creating}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { uuid } from '@/core/utils/uuid';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
import { useWritingForm } from '../../contexts/WritingFormContext';
|
||||
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
|
||||
import { WritingWelcome } from './WritingWelcome';
|
||||
|
||||
/**
|
||||
@@ -42,13 +42,25 @@ import { WritingWelcome } from './WritingWelcome';
|
||||
*
|
||||
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 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 [threadPrepared, setThreadPrepared] = useState(false);
|
||||
const [chatModel, setChatModel] = useState('');
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { articleTypes } = useArticleTypes();
|
||||
const { applyProposal } = useWritingForm();
|
||||
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
|
||||
const formCtx = useWritingFormOptional();
|
||||
const applyProposal = onApproveProp ?? formCtx?.applyProposal;
|
||||
|
||||
// 首个模型可用时默认选中
|
||||
useEffect(() => {
|
||||
@@ -98,8 +110,8 @@ export function WritingSetupChat() {
|
||||
|
||||
const handleApprove = useCallback(
|
||||
async (payload: SetupWritingApprovalPayload) => {
|
||||
applyProposal(payload);
|
||||
toast.success('已应用到左侧表单,可继续编辑或开始写作');
|
||||
applyProposal?.(payload);
|
||||
toast.success('已应用到表单,可继续编辑');
|
||||
},
|
||||
[applyProposal],
|
||||
);
|
||||
@@ -119,8 +131,8 @@ export function WritingSetupChat() {
|
||||
{!showWelcome && (
|
||||
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">写作助手</h2>
|
||||
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好右侧配置</span>
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<span className="text-muted-foreground text-xs">{hint}</span>
|
||||
</header>
|
||||
)}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export function WritingWelcome() {
|
||||
<div className="flex flex-1 flex-col justify-center gap-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
|
||||
欢迎使用「妙笔 AI」智力打字协作工坊
|
||||
欢迎使用「ai写作」智力打字协作工坊
|
||||
</h2>
|
||||
<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;
|
||||
materialSource: 'general' | 'notebook';
|
||||
setMaterialSource: (v: 'general' | 'notebook') => void;
|
||||
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
|
||||
selectedNotebookId: number | null;
|
||||
setSelectedNotebookId: (v: number | null) => void;
|
||||
/** 选中笔记本下的文档 id 列表(缩小检索范围)。 */
|
||||
selectedDocumentIds: number[];
|
||||
setSelectedDocumentIds: (v: number[]) => void;
|
||||
modelName: string;
|
||||
setModelName: (v: string) => void;
|
||||
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
|
||||
@@ -56,7 +62,17 @@ export function useWritingFormOptional(): WritingFormValue | null {
|
||||
return useContext(WritingFormContext);
|
||||
}
|
||||
|
||||
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'general' | 'notebook' }) {
|
||||
export function WritingFormProvider({
|
||||
children,
|
||||
initialMaterialSource,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialMaterialSource?: 'general' | 'notebook';
|
||||
initialNotebookId?: number | null;
|
||||
initialDocumentIds?: number[];
|
||||
}) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [formExpanded, setFormExpanded] = useState(false);
|
||||
const [showOutline, setShowOutline] = useState(true);
|
||||
@@ -66,6 +82,8 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
||||
const [modelName, setModelName] = useState('');
|
||||
|
||||
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
|
||||
@@ -100,10 +118,12 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
|
||||
audience, setAudience,
|
||||
strictMode, setStrictMode,
|
||||
materialSource, setMaterialSource,
|
||||
selectedNotebookId, setSelectedNotebookId,
|
||||
selectedDocumentIds, setSelectedDocumentIds,
|
||||
modelName, setModelName,
|
||||
applyProposal,
|
||||
}),
|
||||
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal],
|
||||
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
|
||||
);
|
||||
|
||||
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
createResearchTemplate,
|
||||
deleteResearchTemplate,
|
||||
listResearchTemplates,
|
||||
updateResearchTemplate,
|
||||
} from '../api/ai-writing-research-templates';
|
||||
import type {
|
||||
CreateResearchTemplateRequest,
|
||||
ResearchTemplate,
|
||||
UpdateResearchTemplateRequest,
|
||||
} from '../api/ai-writing-research-templates';
|
||||
|
||||
export function useResearchTemplates() {
|
||||
const [templates, setTemplates] = useState<ResearchTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setTemplates(await listResearchTemplates());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const create = useCallback(
|
||||
async (data: CreateResearchTemplateRequest): Promise<ResearchTemplate> => {
|
||||
const created = await createResearchTemplate(data);
|
||||
setTemplates((prev) =>
|
||||
[...prev, created].sort((a, b) => a.sort_order - b.sort_order),
|
||||
);
|
||||
return created;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string, data: UpdateResearchTemplateRequest): Promise<ResearchTemplate> => {
|
||||
const updated = await updateResearchTemplate(id, data);
|
||||
setTemplates((prev) =>
|
||||
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
|
||||
);
|
||||
return updated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await deleteResearchTemplate(id);
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return { templates, loading, error, refresh, create, update, remove };
|
||||
}
|
||||
@@ -81,8 +81,8 @@
|
||||
* -d '{"retention_days": 0}'
|
||||
* ──────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { PanelRightOpen } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ArrowLeft, PanelRightOpen } from 'lucide-react';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GroupImperativeHandle } from 'react-resizable-panels';
|
||||
@@ -93,6 +93,13 @@ import { CanvasUIProvider } from '../contexts/CanvasUIContext';
|
||||
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
|
||||
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
||||
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
|
||||
import { ResearchSidebar } from '../components/ai-writing/ResearchSidebar';
|
||||
import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage';
|
||||
import StudioNotebooksPage from '@/pages/StudioNotebooksPage';
|
||||
import { ChatRuntime } from '@/pages/WorkspaceRoutes';
|
||||
|
||||
/** 右侧内嵌视图:笔记本详情 / 所有空间列表 / null(写作) */
|
||||
type RightView = { kind: 'notebook'; id: number } | { kind: 'allSpaces' } | null;
|
||||
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
|
||||
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
|
||||
import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext';
|
||||
@@ -210,6 +217,81 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态:
|
||||
* 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。
|
||||
*/
|
||||
function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
|
||||
const { state } = useAIWriting();
|
||||
const { isHistoryMode } = useAIWritingHistory();
|
||||
|
||||
// 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容
|
||||
// (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。
|
||||
const [rightView, setRightView] = useState<RightView>(null);
|
||||
const overlayActive = rightView != null;
|
||||
const viewingNotebookId = rightView?.kind === 'notebook' ? rightView.id : null;
|
||||
|
||||
// 进入写作(开始生成 / 查看历史)后收起研究模板侧栏;嵌入态(studioToken)始终不展示。
|
||||
const showSidebar = !studioToken && state.status === 'idle' && !isHistoryMode;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex">
|
||||
{/* 需求 0616 #5:课题研究左栏(研究模板 + 笔记本)。进入写作后收起。 */}
|
||||
{showSidebar && (
|
||||
<ResearchSidebar
|
||||
viewingNotebookId={viewingNotebookId}
|
||||
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
|
||||
onOpenAllSpaces={() => setRightView({ kind: 'allSpaces' })}
|
||||
/>
|
||||
)}
|
||||
{/* 右侧:写作布局常驻(仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程),
|
||||
查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<div className={cn('absolute inset-0', overlayActive && 'hidden')}>
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</div>
|
||||
{overlayActive && (
|
||||
<div className="absolute inset-0 flex flex-col bg-background">
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightView(null)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回写作
|
||||
</button>
|
||||
{rightView.kind === 'notebook' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightView({ kind: 'allSpaces' })}
|
||||
className="ml-1 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
所有空间
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{rightView.kind === 'notebook' ? (
|
||||
// 笔记本详情页内部用 ChatBox(useArtifacts) → 需 ChatRuntime 提供 Provider。
|
||||
<ChatRuntime>
|
||||
<StudioNotebookDetailPage key={rightView.id} notebookId={rightView.id} />
|
||||
</ChatRuntime>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<StudioNotebooksPage
|
||||
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AIWritingPage({
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
@@ -227,12 +309,19 @@ export default function AIWritingPage({
|
||||
<CanvasUIProvider>
|
||||
<AIWritingHistoryProvider>
|
||||
<AIWritingProvider>
|
||||
<WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}>
|
||||
<WritingFormProvider
|
||||
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</div>
|
||||
<AIWritingBody
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</WritingFormProvider>
|
||||
|
||||
@@ -0,0 +1,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 AIWritingPage from "../pages/AIWritingPage";
|
||||
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
|
||||
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
|
||||
import ResearchTemplateEditorPage from "../pages/ResearchTemplateEditorPage";
|
||||
|
||||
export default function OpenCanvasRoutes() {
|
||||
return (
|
||||
@@ -11,6 +13,9 @@ export default function OpenCanvasRoutes() {
|
||||
<Route index element={<CanvasHomePage />} />
|
||||
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
|
||||
<Route path="article-types" element={<ArticleTypesConfigPage />} />
|
||||
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
|
||||
<Route path="research-templates/new" element={<ResearchTemplateEditorPage />} />
|
||||
<Route path="research-templates/edit/:id" element={<ResearchTemplateEditorPage />} />
|
||||
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -31,15 +31,31 @@ import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
export default function AgentChatPage({
|
||||
embeddedAgentId,
|
||||
chatBasePath,
|
||||
}: {
|
||||
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
|
||||
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
|
||||
embeddedAgentId?: string;
|
||||
/** 智能体对话所在的路由前缀(到 `/chats` 为止,不含 thread_id)。
|
||||
* 默认 `${routeBase}/agents/${agent_id}/chats`;任务深链把对话嵌进「任务管理」布局时,
|
||||
* 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`,
|
||||
* 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */
|
||||
chatBasePath?: string;
|
||||
} = {}) {
|
||||
const routeBase = useRouteBase();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { agent_id } = useParams<{
|
||||
const { agent_id: agentIdFromPath } = useParams<{
|
||||
agent_id: string;
|
||||
}>();
|
||||
const agent_id = embeddedAgentId ?? agentIdFromPath;
|
||||
const isEmbedded = !!embeddedAgentId;
|
||||
// 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。
|
||||
const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`;
|
||||
|
||||
// If ChatPage stashed an opening message for this agent, skip the landing
|
||||
// screen on first render so the user doesn't see a flash of the welcome UI
|
||||
@@ -92,7 +108,7 @@ export default function AgentChatPage() {
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/agents/${agent_id}/chats/${createdThreadId}`,
|
||||
`${window.location.pathname}${window.location.search}#${agentChatsBase}/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
@@ -208,8 +224,12 @@ export default function AgentChatPage() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
|
||||
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
|
||||
router.push(
|
||||
`/page/workspace/agents/${agent_id}/chats/new`,
|
||||
isEmbedded
|
||||
? `/page/workspace/chats/new?agent=${agent_id}`
|
||||
: `${agentChatsBase}/new`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -285,6 +305,8 @@ export default function AgentChatPage() {
|
||||
thread={thread}
|
||||
paddingBottom={showFollowups ? 96 : 24}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
// 输入框在流内(MessageList 下方),按钮贴对话区底部即可。
|
||||
scrollButtonClassName="bottom-2"
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
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,
|
||||
} from "@/strategy-components/api/business-mapping";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
|
||||
export default function BusinessChainMappingPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
// 整片脱敏:业务链条映射页展示文本(链条名/描述/席位名/标题)。输入框 Hook 自动跳过。
|
||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
const [rows, setRows] = useState<BusinessMapping[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
@@ -160,7 +163,7 @@ export default function BusinessChainMappingPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}>
|
||||
<div ref={dzRef} className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}>
|
||||
<div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8">
|
||||
|
||||
{/* ── 页头 banner ── */}
|
||||
|
||||
@@ -34,7 +34,7 @@ import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-rout
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PanelLeftOpen } from "lucide-react";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
@@ -130,6 +130,10 @@ export default function ChatPage() {
|
||||
const browserContextAutoSubmitRef = useRef<string | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
// 已选 agent 的存在性校验需要更宽的来源:侧边栏可选未配置进管理弹窗的 agent
|
||||
// (用户自己的智能体),这些也算有效,故把「我的 / 广场」一并纳入校验集(缓存共享)。
|
||||
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
@@ -142,7 +146,7 @@ export default function ChatPage() {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
// Pre-select the agent the user last started a conversation with so a
|
||||
// fresh chat defaults to it. Validated against the selector list below.
|
||||
// fresh chat defaults to it. Validated against the known agent list below.
|
||||
setSelectedAgentId(getLastAgentId());
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
@@ -152,10 +156,14 @@ export default function ChatPage() {
|
||||
// dead agent. Wait for the list to load before deciding.
|
||||
useEffect(() => {
|
||||
if (selectorLoading || !selectedAgentId) return;
|
||||
if (!selectorAgents.some((a) => a.id === selectedAgentId)) {
|
||||
// 校验集 = 管理弹窗 ∪ 我的 ∪ 广场;任一存在即视为有效(含用户未配置进管理弹窗
|
||||
// 的自有智能体)。三者都还没加载出来时不做清除,避免把恢复值误删。
|
||||
const known = [...selectorAgents, ...mineAgents, ...squareAgents];
|
||||
if (known.length === 0) return;
|
||||
if (!known.some((a) => a.id === selectedAgentId)) {
|
||||
setSelectedAgentId(null);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, selectorLoading]);
|
||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
@@ -493,6 +501,8 @@ export default function ChatPage() {
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
|
||||
scrollButtonClassName="bottom-40"
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) =>
|
||||
handleSubmit({ text, files: [] })
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
BookMarkedIcon,
|
||||
BrainCircuitIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
} from "lucide-react";
|
||||
@@ -23,7 +22,9 @@ import {
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { getAccessToken } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -369,7 +370,7 @@ export default function IframeChatPage() {
|
||||
}, [handleInterrupt, handleSubmit]);
|
||||
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
const bottomBarHeightClass = "pb-28";
|
||||
const bottomBarHeightClass = "pb-24";
|
||||
const modeButtons = [
|
||||
{
|
||||
key: "writing",
|
||||
@@ -377,8 +378,7 @@ export default function IframeChatPage() {
|
||||
icon: FileTextIcon,
|
||||
active: !!settings.context.writing_mode,
|
||||
onClick: toggleWritingMode,
|
||||
activeClassName:
|
||||
"border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100",
|
||||
activeClassName: "text-amber-600 hover:text-amber-700",
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
@@ -386,8 +386,7 @@ export default function IframeChatPage() {
|
||||
icon: BrainCircuitIcon,
|
||||
active: settings.context.memory_injection_enabled ?? true,
|
||||
onClick: toggleMemoryInjection,
|
||||
activeClassName:
|
||||
"border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100",
|
||||
activeClassName: "text-blue-600 hover:text-blue-700",
|
||||
},
|
||||
{
|
||||
key: "references",
|
||||
@@ -396,8 +395,7 @@ export default function IframeChatPage() {
|
||||
active: ragModeEnabled,
|
||||
onClick: toggleRagMode,
|
||||
disabled: ragModeLocked,
|
||||
activeClassName:
|
||||
"border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100",
|
||||
activeClassName: "text-emerald-600 hover:text-emerald-700",
|
||||
},
|
||||
{
|
||||
key: "pro",
|
||||
@@ -405,8 +403,7 @@ export default function IframeChatPage() {
|
||||
icon: GraduationCapIcon,
|
||||
active: proModeEnabled,
|
||||
onClick: toggleProMode,
|
||||
activeClassName:
|
||||
"border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100",
|
||||
activeClassName: "text-violet-600 hover:text-violet-700",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -496,29 +493,32 @@ export default function IframeChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-40 border-t bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-2 px-4 py-4">
|
||||
{modeButtons.map((button) => {
|
||||
const Icon = button.icon;
|
||||
return (
|
||||
<Button
|
||||
key={button.key}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={button.disabled || isUploading}
|
||||
onClick={button.onClick}
|
||||
className={cn(
|
||||
"gap-1.5 rounded-full border border-transparent bg-muted/40 text-muted-foreground shadow-none hover:bg-muted hover:text-foreground",
|
||||
button.active && button.activeClassName,
|
||||
button.disabled && "cursor-not-allowed opacity-80",
|
||||
)}
|
||||
>
|
||||
{button.active && <CheckIcon className="size-3.5" />}
|
||||
<Icon className="size-3.5" />
|
||||
<span className="text-xs font-normal">{button.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-0 px-0 py-1">
|
||||
{modeButtons.map((button) => {
|
||||
const Icon = button.icon;
|
||||
return (
|
||||
<Tooltip key={button.key} content={button.label}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={button.label}
|
||||
disabled={button.disabled || isUploading}
|
||||
onClick={button.onClick}
|
||||
className={cn(
|
||||
"size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
||||
button.active && button.activeClassName,
|
||||
button.disabled && "cursor-not-allowed opacity-80",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,12 @@ import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
|
||||
import {
|
||||
ExtractTemplateManageDialog,
|
||||
FolderManageDialog,
|
||||
FolderSelect,
|
||||
TemplateSelect,
|
||||
} from "@/components/wiki/KnowledgeManage";
|
||||
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
|
||||
import { WikiContent } from "@/components/wiki/WikiContent";
|
||||
import { WikiHeader } from "@/components/wiki/WikiHeader";
|
||||
@@ -28,6 +34,7 @@ import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import {
|
||||
useKnowledgeActions,
|
||||
useKnowledgeDuplicates,
|
||||
useKnowledgeFolders,
|
||||
useKnowledgeNote,
|
||||
useKnowledgeNotes,
|
||||
useNoteWikilinks,
|
||||
@@ -70,11 +77,13 @@ function CreateNoteDialog({
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setContent("");
|
||||
setFolder(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -91,6 +100,7 @@ function CreateNoteDialog({
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
status: "approved",
|
||||
folder,
|
||||
});
|
||||
toast.success("知识已创建");
|
||||
onOpenChange(false);
|
||||
@@ -119,6 +129,10 @@ function CreateNoteDialog({
|
||||
value={tags}
|
||||
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
|
||||
placeholder="Markdown 正文"
|
||||
rows={12}
|
||||
@@ -152,6 +166,8 @@ function BatchImportDialog({
|
||||
const [skipExisting, setSkipExisting] = useState(true);
|
||||
// Optional model override for extraction (null = configured default).
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [template, setTemplate] = useState<string | null>(null);
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
|
||||
const handleRun = async () => {
|
||||
try {
|
||||
@@ -160,8 +176,21 @@ function BatchImportDialog({
|
||||
skip_existing: skipExisting,
|
||||
status: "approved",
|
||||
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,
|
||||
template_id: template,
|
||||
folder,
|
||||
});
|
||||
if (result.queued) {
|
||||
toast.success(
|
||||
`已转入后台导入 ${result.processed} 个线程,完成后会陆续出现在左侧列表(可稍后刷新查看)`,
|
||||
);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
|
||||
if (result.llm_fallback && result.llm_fallback > 0) {
|
||||
// The model failed on some threads — they were saved via rule-based
|
||||
@@ -217,6 +246,25 @@ function BatchImportDialog({
|
||||
triggerClassName="h-9 w-[11rem]"
|
||||
/>
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
@@ -244,12 +292,16 @@ function ImportFileDialog({
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [template, setTemplate] = useState<string | null>(null);
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setModel(null);
|
||||
setTemplate(null);
|
||||
setFolder(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
@@ -265,6 +317,8 @@ function ImportFileDialog({
|
||||
tags: tags.trim() || undefined,
|
||||
status: "approved",
|
||||
model_name: model,
|
||||
template_id: template,
|
||||
folder,
|
||||
},
|
||||
});
|
||||
const count = result.notes?.length ?? 1;
|
||||
@@ -330,6 +384,25 @@ function ImportFileDialog({
|
||||
triggerClassName="h-9 w-[11rem]"
|
||||
/>
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
@@ -478,6 +551,8 @@ export default function KnowledgeBasePage() {
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = 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 params = useMemo(
|
||||
@@ -485,14 +560,19 @@ export default function KnowledgeBasePage() {
|
||||
keyword: debouncedKeyword || undefined,
|
||||
source_type: sourceType === "all" ? undefined : sourceType,
|
||||
status: status === "all" ? undefined : status,
|
||||
limit: 500,
|
||||
limit: 1000,
|
||||
}),
|
||||
[debouncedKeyword, sourceType, status],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useKnowledgeNotes(params);
|
||||
const { data: folders = [] } = useKnowledgeFolders();
|
||||
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: wikiLinks } = useNoteWikilinks(noteId);
|
||||
@@ -571,6 +651,8 @@ export default function KnowledgeBasePage() {
|
||||
onDedup={() => setDedupOpen(true)}
|
||||
onGraph={() => navigate("/page/workspace/knowledge/graph")}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onManageFolders={() => setFoldersOpen(true)}
|
||||
onManageTemplates={() => setTemplatesOpen(true)}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -636,6 +718,8 @@ export default function KnowledgeBasePage() {
|
||||
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
|
||||
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
|
||||
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
|
||||
<FolderManageDialog open={foldersOpen} onOpenChange={setFoldersOpen} />
|
||||
<ExtractTemplateManageDialog open={templatesOpen} onOpenChange={setTemplatesOpen} />
|
||||
<VaultPageDialog
|
||||
path={vaultPath}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
@@ -11,6 +12,11 @@ import {
|
||||
readExternalLoginParams,
|
||||
resolveThemeFromQuery,
|
||||
} from '@/core/auth/external-login';
|
||||
import {
|
||||
fetchCopTaskDetail,
|
||||
persistCopTaskDetail,
|
||||
resolveTaskDeeplinkTarget,
|
||||
} from '@/core/auth/task-deeplink';
|
||||
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
||||
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||
import {
|
||||
@@ -34,6 +40,7 @@ type LoginStatus = 'loading' | 'success' | 'error';
|
||||
export default function LoginPage() {
|
||||
const { username } = useParams<{ username?: string }>();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
const sidebarEnabled = useMemo(
|
||||
@@ -60,6 +67,9 @@ export default function LoginPage() {
|
||||
const gatePassword = searchParams.get('password') ?? '';
|
||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||
const authToken = searchParams.get('authToken')?.trim() ?? '';
|
||||
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
|
||||
const taskId = searchParams.get('taskId')?.trim() ?? '';
|
||||
const goPath = searchParams.get('goPath')?.trim() ?? '';
|
||||
const externalParams = useMemo(
|
||||
() => ({
|
||||
theme: searchParams.get('theme') ?? '',
|
||||
@@ -145,6 +155,27 @@ export default function LoginPage() {
|
||||
persistAccessToken(accessToken);
|
||||
persistSidebarEnabled(sidebarEnabled);
|
||||
setStatus('success');
|
||||
|
||||
// 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页;
|
||||
// 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。
|
||||
if (taskId && goPath) {
|
||||
try {
|
||||
const detail = await fetchCopTaskDetail(taskId);
|
||||
if (cancelled) return;
|
||||
if (detail) {
|
||||
persistCopTaskDetail(detail);
|
||||
const target = await resolveTaskDeeplinkTarget(goPath, detail);
|
||||
if (cancelled) return;
|
||||
if (target) {
|
||||
navigate(target.path, { replace: true, state: target.state });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 深链失败不阻断登录,落默认首页 */
|
||||
}
|
||||
}
|
||||
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
@@ -164,6 +195,7 @@ export default function LoginPage() {
|
||||
}, [
|
||||
resolvedUsername,
|
||||
router,
|
||||
navigate,
|
||||
sidebarEnabled,
|
||||
yUserId,
|
||||
toolToken,
|
||||
@@ -171,6 +203,8 @@ export default function LoginPage() {
|
||||
onlineTicket,
|
||||
authToken,
|
||||
gatePassword,
|
||||
taskId,
|
||||
goPath,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
TagIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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 {
|
||||
Dialog,
|
||||
@@ -24,9 +28,9 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAdminRecommendedQuestions,
|
||||
useCreateRecommendedQuestion,
|
||||
useDeleteRecommendedQuestion,
|
||||
useRecommendedQuestions,
|
||||
useReorderRecommendedQuestions,
|
||||
useUpdateRecommendedQuestion,
|
||||
} from "@/core/recommended-questions/hooks";
|
||||
@@ -65,7 +69,7 @@ export default function RecommendedQuestionsPage({
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { theme } = useTheme();
|
||||
const { questions, isLoading, error } = useRecommendedQuestions();
|
||||
const { questions, isLoading, error } = useAdminRecommendedQuestions();
|
||||
const createMutation = useCreateRecommendedQuestion();
|
||||
const updateMutation = useUpdateRecommendedQuestion();
|
||||
const deleteMutation = useDeleteRecommendedQuestion();
|
||||
@@ -75,11 +79,31 @@ export default function RecommendedQuestionsPage({
|
||||
const [formTitle, setFormTitle] = useState("");
|
||||
const [formContent, setFormContent] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<RecommendedQuestion | null>(null);
|
||||
const [tagTarget, setTagTarget] = useState<RecommendedQuestion | null>(null);
|
||||
|
||||
// drag state
|
||||
const [dragIndex, setDragIndex] = 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() {
|
||||
setFormTitle("");
|
||||
setFormContent("");
|
||||
@@ -200,6 +224,16 @@ export default function RecommendedQuestionsPage({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isLoading && !error && questions.length > 0 && (
|
||||
<TagSearchBar
|
||||
targetType="recommended_question"
|
||||
searchPlaceholder="搜索标题 / 内容"
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
@@ -216,15 +250,19 @@ export default function RecommendedQuestionsPage({
|
||||
添加第一个问题
|
||||
</Button>
|
||||
</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 ">
|
||||
{questions.map((q, index) => (
|
||||
{visibleQuestions.map((q, index) => (
|
||||
<div
|
||||
key={q.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={() => handleDrop(index)}
|
||||
draggable={!isFiltering}
|
||||
onDragStart={() => !isFiltering && handleDragStart(index)}
|
||||
onDragOver={(e) => !isFiltering && handleDragOver(e, index)}
|
||||
onDrop={() => !isFiltering && handleDrop(index)}
|
||||
onDragEnd={() => {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
@@ -235,7 +273,13 @@ export default function RecommendedQuestionsPage({
|
||||
: "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="truncate text-sm font-medium text-[#18181b] dark:text-white">
|
||||
{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]">
|
||||
{q.content}
|
||||
</div>
|
||||
{(q.tags?.length ?? 0) > 0 && (
|
||||
<TagChipList tags={q.tags!} className="mt-2" />
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -308,6 +365,17 @@ export default function RecommendedQuestionsPage({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 打标签弹窗(按岗位分发推荐问题) */}
|
||||
{tagTarget && (
|
||||
<ResourceTagDialog
|
||||
open={tagTarget !== null}
|
||||
onOpenChange={(o) => !o && setTagTarget(null)}
|
||||
targetType="recommended_question"
|
||||
targetId={tagTarget.id}
|
||||
title={`为「${tagTarget.title}」打标签`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<Dialog
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
@@ -19,6 +19,8 @@ import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainList
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import { AgentModuleRoutes } from "./AgentModulePage";
|
||||
import { AGENT_MODULES } from "@/components/workspace/agents/agent-module-sidebar";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import BusinessChainMappingPage from "./BusinessChainMappingPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
@@ -41,6 +43,7 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import PositionsPage from "./PositionsPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
@@ -48,6 +51,20 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
|
||||
/**
|
||||
* 任务深链:把智能体问答嵌进「任务管理」布局。读路由参数 agent_id,给 AgentChatPage
|
||||
* 传入以 taskBase 为前缀的对话路由(开新会话 / URL 改写都停留在任务管理上下文,
|
||||
* 侧边栏保持对应业务高亮)。
|
||||
*/
|
||||
function TaskEmbeddedAgentChat({ taskBase }: { taskBase: string }) {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
return (
|
||||
<ChatRuntime>
|
||||
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} />
|
||||
</ChatRuntime>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
return (
|
||||
<RouteBaseProvider value="/page/strategy">
|
||||
@@ -75,6 +92,10 @@ export default function StrategyRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 需求 0616 #3/#4:zzJC / zzXD 「按类型聚合」模块页(重做,独立路由)。 */}
|
||||
<Route path="qa/zzjc/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzjc} />} />
|
||||
<Route path="qa/zzxd/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzxd} />} />
|
||||
|
||||
<Route path="task-management" element={<TaskManagement />}>
|
||||
<Route index element={<Navigate to="xk-task-jc" replace />} />
|
||||
<Route
|
||||
@@ -107,6 +128,21 @@ export default function StrategyRoutes() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* 任务深链(3qfx / 3qfx-A):把智能体问答 / 会商嵌进 3QFX 业务上下文。 */}
|
||||
<Route
|
||||
path="plan-task-sentiment/roundtable"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan-task-sentiment/agent/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task-sentiment" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan-task/plan-task-fx"
|
||||
element={
|
||||
@@ -117,6 +153,21 @@ export default function StrategyRoutes() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* 任务深链(rwfx / rwfx-A):嵌进 任务FX 业务上下文。 */}
|
||||
<Route
|
||||
path="plan-task/plan-task-fx/roundtable"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan-task/plan-task-fx/agent/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan-task/plan-task-action"
|
||||
element={
|
||||
@@ -127,6 +178,21 @@ export default function StrategyRoutes() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* 任务深链(xdfx / xdfx-A):嵌进 XDFX 业务上下文。 */}
|
||||
<Route
|
||||
path="plan-task/plan-task-action/roundtable"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan-task/plan-task-action/agent/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-action" />
|
||||
}
|
||||
/>
|
||||
<Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} />
|
||||
<Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} />
|
||||
<Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} />
|
||||
@@ -289,6 +355,16 @@ export default function StrategyRoutes() {
|
||||
path="admin/tags"
|
||||
element={<TagsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/positions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PositionsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -210,8 +210,15 @@ function SourcesDialog({
|
||||
}
|
||||
|
||||
// ── 主页面 ──────────────────────────────────────────────
|
||||
export default function StudioNotebookDetailPage() {
|
||||
const { notebookId } = useParams<{ notebookId: string }>();
|
||||
export default function StudioNotebookDetailPage({
|
||||
notebookId: notebookIdProp,
|
||||
}: {
|
||||
/** 内嵌使用(如课题研究右侧)时直接传入;缺省则从路由参数取。 */
|
||||
notebookId?: number;
|
||||
} = {}) {
|
||||
const params = useParams<{ notebookId: string }>();
|
||||
const notebookId =
|
||||
notebookIdProp != null ? String(notebookIdProp) : params.notebookId;
|
||||
const { theme } = useTheme(); // 跳转「我的空间」时带上当前主题 key
|
||||
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
|
||||
const { models } = useModels();
|
||||
|
||||
@@ -44,21 +44,17 @@ function NotebookCard({
|
||||
isDark,
|
||||
onRename,
|
||||
onDelete,
|
||||
onOpen,
|
||||
}: {
|
||||
nb: StudioNotebook;
|
||||
isDark: boolean;
|
||||
onRename: (nb: StudioNotebook) => void;
|
||||
onDelete: (nb: StudioNotebook) => void;
|
||||
/** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */
|
||||
onOpen?: (id: number) => void;
|
||||
}) {
|
||||
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 }}
|
||||
>
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className="flex flex-1 flex-col p-5"
|
||||
>
|
||||
const inner = (
|
||||
<>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
|
||||
{nb.avatar_url ? (
|
||||
@@ -76,7 +72,27 @@ function NotebookCard({
|
||||
</span>
|
||||
<span className={`text-[13px] ${isDark ? "text-[#8b93a7]" : "text-[#666]"}`}>{nb.source_count} 个来源</span>
|
||||
</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 */}
|
||||
<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 { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
@@ -216,6 +237,7 @@ export default function StudioNotebooksPage() {
|
||||
isDark={isDark}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
onOpen={onOpenNotebook}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
|
||||
@@ -32,6 +32,7 @@ import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
import SkillsPage from './SkillsPage';
|
||||
import UsersPage from './UsersPage';
|
||||
@@ -39,6 +40,7 @@ import AdminUserActivityPage from './AdminUserActivityPage';
|
||||
import AdminMemoryPage from './AdminMemoryPage';
|
||||
import AdminUserHubPage from './AdminUserHubPage';
|
||||
import TagsPage from './TagsPage';
|
||||
import PositionsPage from './PositionsPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
@@ -87,6 +89,10 @@ export function WorkspaceLayout({
|
||||
insetOverflowHidden?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
}) {
|
||||
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
||||
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
||||
const isGeneralQa = useLocation().pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? '0rem'
|
||||
: pageSidebarDark
|
||||
@@ -113,7 +119,9 @@ export function WorkspaceLayout({
|
||||
}
|
||||
key={String(compactWorkspaceSidebar)}
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen={defaultHistorySidebarOpen ?? !compactWorkspaceSidebar}
|
||||
defaultOpen={
|
||||
defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar)
|
||||
}
|
||||
>
|
||||
{!workspaceSidebarHidden && (
|
||||
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
|
||||
@@ -139,6 +147,21 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用问答新对话内容区:默认渲染 ChatPage;当 URL 带 ``?agent=<id>``(点击左栏
|
||||
* 「常用智能体」时由侧边栏写入)时,**在右侧问答区原地嵌入**该智能体的对话页
|
||||
* (AgentChatPage),路由仍停留在 /chats/new,不跳转出去、不更换左侧栏。
|
||||
* key 绑定 location.key —— 每次点击(即便同一 agent)都重开一个干净的会话。
|
||||
*/
|
||||
function NewChatContent() {
|
||||
const location = useLocation();
|
||||
const agentId = new URLSearchParams(location.search).get('agent');
|
||||
if (agentId) {
|
||||
return <AgentChatPage key={`${agentId}:${location.key}`} embeddedAgentId={agentId} />;
|
||||
}
|
||||
return <ChatPage />;
|
||||
}
|
||||
|
||||
function NewChatEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
@@ -157,7 +180,7 @@ function NewChatEntryRoute({
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
<NewChatContent />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
@@ -475,6 +498,18 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/positions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PositionsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
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
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
@@ -212,9 +213,11 @@ export function BusinessChainListDialog({
|
||||
onOpenChange,
|
||||
onConfigure,
|
||||
}: BusinessChainListDialogProps) {
|
||||
// 整片脱敏:弹窗内链条列表(链条名/描述/席位名/标题)。
|
||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
|
||||
<DialogContent ref={dzRef} className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LayersIcon className="size-5 text-primary" />
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
import { filterRecommendCandidates } from "../api/recommend";
|
||||
@@ -161,9 +162,12 @@ export function BusinessChainPicker({
|
||||
onConfirm({ agents, mode: "chain", chain: chainMeta });
|
||||
};
|
||||
|
||||
// 整片脱敏:链条卡片区(链条名/描述/席位智能体名)。输入框不受影响(Hook 跳过)。
|
||||
const dzRef = useDesensitizeSubtree<HTMLElement>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<section ref={dzRef} className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{/* 筛选:我的 / 公共 */}
|
||||
<div className="mb-2 flex shrink-0 items-center gap-1 self-start rounded-lg border border-border bg-muted/40 p-0.5">
|
||||
{(["mine", "public"] as const).map((s) => (
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { getAvatar } from "./Avatars";
|
||||
@@ -137,7 +138,7 @@ export function DagFlowChart({
|
||||
return (
|
||||
<FlowNode
|
||||
key={agentId}
|
||||
label={meta.name}
|
||||
label={desensitize(meta.name)}
|
||||
avatarType={meta.avatarType}
|
||||
state={state}
|
||||
compact={compact}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { CheckIcon, Loader2Icon, Network, X } from "lucide-react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { getAvatar } from "./Avatars";
|
||||
@@ -400,7 +401,7 @@ export function DagFlowMiniPanel({
|
||||
return (
|
||||
<MiniNode
|
||||
key={agentId}
|
||||
label={meta.name}
|
||||
label={desensitize(meta.name)}
|
||||
avatarType={meta.avatarType}
|
||||
state={state}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { getAvatar } from "./Avatars";
|
||||
@@ -236,7 +237,7 @@ export function DispatchChainModal({
|
||||
done={node.state === "done"}
|
||||
/>
|
||||
<FlowNode
|
||||
label={node.name}
|
||||
label={desensitize(node.name)}
|
||||
avatarType={node.avatarType}
|
||||
state={node.state}
|
||||
order={node.order}
|
||||
@@ -277,7 +278,7 @@ export function DispatchChainModal({
|
||||
<div key={d.id} className="flex gap-2.5">
|
||||
<div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 text-xs font-semibold text-foreground/80">{d.name}</div>
|
||||
<div className="mb-0.5 text-xs font-semibold text-foreground/80">{desensitize(d.name)}</div>
|
||||
<div className="prose prose-sm max-w-none break-words text-sm leading-relaxed text-foreground/80 dark:prose-invert">
|
||||
<MarkdownContent>{d.content || "…"}</MarkdownContent>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import {
|
||||
Step3ReportMode,
|
||||
@@ -707,7 +708,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
lit ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{seat.displayName}
|
||||
{desensitize(seat.displayName)}
|
||||
</span>
|
||||
<span className="block truncate text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground">
|
||||
{lit ? `${seat.delivery!.length} 字` : "无交付"}
|
||||
@@ -771,7 +772,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
{consensusText ? (
|
||||
<div className="text-xs text-slate-700 leading-relaxed dark:text-foreground/80">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
{desensitize(consensusText)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
@@ -830,7 +831,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
{getAvatar(seat.avatarType, "h-5.5 w-5.5")}
|
||||
</span>
|
||||
<span className="text-xs font-black text-slate-800 truncate dark:text-foreground">
|
||||
{seat.displayName}
|
||||
{desensitize(seat.displayName)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0 dark:text-muted-foreground">
|
||||
@@ -858,7 +859,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{seat.delivery!}
|
||||
{desensitize(seat.delivery!)}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">该席位暂无交付内容。</p>
|
||||
|
||||
@@ -18,6 +18,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
@@ -58,7 +59,8 @@ export function MessageBubble({
|
||||
senderNameClassName,
|
||||
)}
|
||||
>
|
||||
{senderName}
|
||||
{/* 智能体名称需脱敏(消息列表里唯一要脱敏的就是名称)。 */}
|
||||
{desensitize(senderName)}
|
||||
</span>
|
||||
{badge && (
|
||||
<span className="roundtable-chat-meta font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
|
||||
@@ -190,6 +192,7 @@ export const sharedMarkdownComponents = {
|
||||
* 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。
|
||||
* 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。
|
||||
*/
|
||||
// 消息列表正文**不脱敏**(仅名称脱敏);MarkdownContent 是会商对话气泡正文渲染器,故原文渲染。
|
||||
export function MarkdownContent({ children }: { children: string }) {
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={sharedMarkdownComponents}>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
@@ -42,7 +43,7 @@ export function ParallelSeatStrip({ batch, selectedSeats, onToggleSeat }: Parall
|
||||
key={seat.agentId}
|
||||
type="button"
|
||||
onClick={() => onToggleSeat(seat.agentId)}
|
||||
title={`${seat.displayName}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`}
|
||||
title={`${desensitize(seat.displayName)}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`}
|
||||
className={cn(
|
||||
"relative shrink-0 rounded-full transition",
|
||||
isSelected ? "ring-2 ring-primary ring-offset-1" : "ring-1 ring-transparent hover:ring-slate-300",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { listAgents, type Agent } from "@/core/agents";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
@@ -1122,7 +1123,7 @@ function AgentCard({
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div>
|
||||
<div className="font-medium text-sm truncate">{desensitize(candidate.name || candidate.agent_id)}</div>
|
||||
{recommended && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
|
||||
推荐
|
||||
@@ -1131,12 +1132,12 @@ function AgentCard({
|
||||
</div>
|
||||
{candidate.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{candidate.description}
|
||||
{desensitize(candidate.description)}
|
||||
</div>
|
||||
)}
|
||||
{reason ? (
|
||||
<div className="text-xs text-primary/80 mt-1 italic">
|
||||
<span className="font-medium not-italic">推荐理由:</span> {reason}
|
||||
<span className="font-medium not-italic">推荐理由:</span> {desensitize(reason)}
|
||||
</div>
|
||||
) : streaming ? (
|
||||
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
|
||||
|
||||
@@ -56,6 +56,7 @@ import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToB
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import type {
|
||||
@@ -149,10 +150,13 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
setAttachedFiles([]);
|
||||
};
|
||||
|
||||
// 顶部标题栏整片脱敏(消息列表正文在下方,不在此容器内,保持原文)。
|
||||
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Dialogue Header */}
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
|
||||
<h3 className="text-sm font-extrabold text-slate-900">
|
||||
|
||||
@@ -81,6 +81,8 @@ import {
|
||||
getAvatarTypeFor,
|
||||
type RoleEntry,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
@@ -405,10 +407,13 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
isOrchestrating ||
|
||||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
|
||||
|
||||
// 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。
|
||||
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
const chatBody = (
|
||||
<>
|
||||
{/* Headline */}
|
||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
||||
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
|
||||
<div>
|
||||
@@ -584,7 +589,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
/>
|
||||
) : (
|
||||
<div className="py-6 text-center text-xs text-slate-400">
|
||||
{seat.displayName} · 等待输出…
|
||||
{desensitize(seat.displayName)} · 等待输出…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -599,7 +604,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
<span className="roundtable-chat-meta font-medium text-slate-600">
|
||||
仅显示{" "}
|
||||
<span className="font-bold text-primary">
|
||||
{seatFilterLabel(seatFilter)}
|
||||
{desensitize(seatFilterLabel(seatFilter))}
|
||||
</span>{" "}
|
||||
的发言
|
||||
</span>
|
||||
@@ -615,7 +620,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
)}
|
||||
{seatFilter && visibleDialogues.length === 0 ? (
|
||||
<div className="py-10 text-center text-xs text-slate-400">
|
||||
没有匹配「{seatFilterLabel(seatFilter)}」的消息
|
||||
没有匹配「{desensitize(seatFilterLabel(seatFilter))}」的消息
|
||||
</div>
|
||||
) : (
|
||||
visibleDialogues.map((msg) => (
|
||||
@@ -878,7 +883,7 @@ export function Step2Message({
|
||||
title={
|
||||
isAvatarSelected
|
||||
? "取消筛选,显示全部发言"
|
||||
: `只看 ${msg.sender} 的发言`
|
||||
: `只看 ${desensitize(msg.sender)} 的发言`
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
|
||||
|
||||
@@ -116,6 +116,8 @@ export function Step2SandboxLayout({
|
||||
threadId={previewThreadId}
|
||||
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
|
||||
allowVirtualMarkdownEdit
|
||||
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
|
||||
disableDesensitize
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { desensitizeDeep } from "@/lib/desensitize";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
|
||||
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
|
||||
@@ -101,10 +102,12 @@ export function Step3DashboardMode({
|
||||
} = dashboard;
|
||||
|
||||
// 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。
|
||||
const report = useMemo(
|
||||
() => dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null),
|
||||
[dashboard.data, savedReport?.dashboardJson],
|
||||
);
|
||||
// 整体深度脱敏后再渲染——大屏(DashboardApp)与自包含 HTML(iframe/新窗口/分享/下载)
|
||||
// 都从这份 report 派生,故在此一处脱敏即覆盖大屏的所有展示文本(会商页非沙箱内容需脱敏)。
|
||||
const report = useMemo(() => {
|
||||
const raw = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null);
|
||||
return raw ? desensitizeDeep(raw) : raw;
|
||||
}, [dashboard.data, savedReport?.dashboardJson]);
|
||||
|
||||
// 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。
|
||||
const dashboardData = useMemo(
|
||||
|
||||
@@ -44,6 +44,7 @@ import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/Co
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode";
|
||||
@@ -407,7 +408,7 @@ export function Step3ReportMode({
|
||||
>
|
||||
{consensusText ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
{desensitize(consensusText)}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 italic">暂无总控结论。</p>
|
||||
@@ -773,14 +774,14 @@ function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[]
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ListOrdered className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{seat.content || "(空)"}
|
||||
{desensitize(seat.content || "(空)")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||
@@ -449,7 +450,7 @@ export function Step3SummaryMode({
|
||||
>
|
||||
{consensusText ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
{desensitize(consensusText)}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 italic">暂无总控结论。</p>
|
||||
@@ -846,14 +847,14 @@ function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[]
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{seat.content || "(空)"}
|
||||
{desensitize(seat.content || "(空)")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
|
||||
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
|
||||
import { CodeBlock } from "@/components/ai-elements/code-block";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
|
||||
|
||||
/**
|
||||
@@ -52,7 +53,7 @@ export function renderStepArgs(
|
||||
return (
|
||||
<>
|
||||
{displayName && (
|
||||
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult>
|
||||
<ChainOfThoughtSearchResult>{desensitize(displayName)}</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
{task && (
|
||||
<div className="roundtable-chat-message mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-slate-600">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 区域级脱敏 Hook —— 把某个容器(DOM 子树)里的**所有文本节点**做脱敏,
|
||||
* 用于「左/右侧面板 + 顶部标题」这类需要整片脱敏的区域:静态标题、动态内容、
|
||||
* 嵌套子组件文本一并覆盖,无需逐串包 `desensitize()`(那样易漏、难维护)。
|
||||
*
|
||||
* 用法:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 只读改文本节点(display-only),不碰 `<input>/<textarea>` 的 value、不改 React 状态/回传值;
|
||||
* - `desensitize` 幂等(替换词不再命中词表),MutationObserver 重入安全;改写前先 disconnect 防自触发循环;
|
||||
* - 想在区域内**保留原文**(如内嵌沙箱/消息列表),给该子树根加 `data-no-desensitize` 属性即可跳过。
|
||||
*
|
||||
* 注意:**不要**用它包「消息列表正文」或「沙箱产物预览」——这两处按需求保持原文,
|
||||
* 应作为独立容器、不挂本 Hook(智能体名称的脱敏单独在 MessageBubble 的 senderName 处理)。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
const _SKIP_TAGS = new Set(["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "IFRAME", "CODE", "PRE"]);
|
||||
|
||||
export function useDesensitizeSubtree<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = ref.current;
|
||||
if (!root || typeof MutationObserver === "undefined") return;
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
|
||||
const apply = () => {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
if (_SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
||||
// 显式标记 data-no-desensitize 的子树保留原文(如内嵌沙箱)。
|
||||
if (parent.closest("[data-no-desensitize]")) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
const edits: Array<[Text, string]> = [];
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
const textNode = current as Text;
|
||||
const original = textNode.nodeValue ?? "";
|
||||
if (original.trim()) {
|
||||
const masked = desensitize(original);
|
||||
if (masked !== original) edits.push([textNode, masked]);
|
||||
}
|
||||
current = walker.nextNode();
|
||||
}
|
||||
if (edits.length === 0) return;
|
||||
// 改写期间停掉观察,避免自身改动再次触发,改完恢复。
|
||||
observer?.disconnect();
|
||||
for (const [textNode, masked] of edits) textNode.nodeValue = masked;
|
||||
observer?.observe(root, { subtree: true, childList: true, characterData: true });
|
||||
};
|
||||
|
||||
apply();
|
||||
observer = new MutationObserver(() => apply());
|
||||
observer.observe(root, { subtree: true, childList: true, characterData: true });
|
||||
return () => observer?.disconnect();
|
||||
});
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
@@ -134,6 +135,8 @@ type ActiveDrag =
|
||||
export default function BusinessChainEditorPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
// 整片脱敏:链条配置页展示文本(链条名/描述/席位名/标题)。可编辑的 input/textarea 不脱敏(Hook 跳过),保证编辑用原文。
|
||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
// ─── 数据 ────────────────────────────────────────────────────────────
|
||||
const [pool, setPool] = useState<RecommendCandidate[]>([]);
|
||||
@@ -804,7 +807,7 @@ export default function BusinessChainEditorPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
|
||||
<div ref={dzRef} className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
|
||||
{/* 顶栏 */}
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
|
||||
<button
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { useRouteNavigate } from "@/core/route-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
|
||||
import { deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains";
|
||||
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
|
||||
@@ -79,6 +80,8 @@ const BRAND =
|
||||
|
||||
export default function BusinessChainListPage() {
|
||||
const navigate = useRouteNavigate();
|
||||
// 整片脱敏:链条列表页展示文本(链条名/描述/席位名/标题)。
|
||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
|
||||
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
|
||||
@@ -233,7 +236,7 @@ export default function BusinessChainListPage() {
|
||||
}, [chains, search, sortBy]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-col bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
|
||||
<div ref={dzRef} className="flex h-full min-h-0 w-full flex-col bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
|
||||
{/* 顶栏:标题 + 描述 + 新建链条 */}
|
||||
<header className="shrink-0 border-b border-zinc-200/80 bg-white/85 px-6 py-4 backdrop-blur-md dark:border-[#1A2F5A] dark:bg-[#122347]/80">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Cpu,
|
||||
Download,
|
||||
History,
|
||||
Layers,
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
Pencil,
|
||||
@@ -54,7 +55,9 @@ import {
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
|
||||
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
@@ -98,6 +101,7 @@ import {
|
||||
} from "@/roundtable-planning/lib/seat-filter";
|
||||
import {
|
||||
buildChainSeatSelection,
|
||||
type BusinessChain,
|
||||
type SeatSelectionResult,
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
|
||||
@@ -110,7 +114,8 @@ import {
|
||||
startJobKeepalive,
|
||||
} from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import { useRoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
|
||||
import { getChain } from "@/roundtable-planning/api/chains";
|
||||
import { getChain, listChains } from "@/roundtable-planning/api/chains";
|
||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
||||
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
|
||||
import { listAgents } from "@/core/agents";
|
||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||
@@ -776,6 +781,34 @@ export default function RoundtablePlanningPage() {
|
||||
[buildCurrentStep2Snapshot, drafts, mergeActiveRun, step2, step3Report, triggerToast],
|
||||
);
|
||||
|
||||
// 需求 0616 #2:Step1 左栏点击某条业务链 → 仅**勾选**该链(不直接进 Step 2)。
|
||||
// 复用 autoChainResult 机制:用户在第一步完成意图澄清后,点「进入多智能体研讨」时
|
||||
// onEnterRoundtable 的 `if (autoChainResult)` 分支会据此跳过选链弹窗、按该链进研讨。
|
||||
// 再次点击同一条链 = 取消勾选。
|
||||
const handleSelectChainFromSidebar = useCallback(
|
||||
async (chain: BusinessChain) => {
|
||||
if (autoChainResult?.chain?.id === chain.id) {
|
||||
setAutoChainResult(null);
|
||||
triggerToast(`已取消业务链「${chain.title}」`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const agents = await listAgents().catch(() => []);
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
const result = buildChainSeatSelection(chain, candidates);
|
||||
if (!result) {
|
||||
triggerToast("⚠️ 该业务链条暂无法选用(匹配到的席位不足)");
|
||||
return;
|
||||
}
|
||||
setAutoChainResult(result);
|
||||
triggerToast(`✅ 已选择业务链「${chain.title}」,完成第一步澄清后点「进入多智能体研讨」`);
|
||||
} catch {
|
||||
triggerToast("❌ 选择业务链失败,请重试");
|
||||
}
|
||||
},
|
||||
[autoChainResult, triggerToast],
|
||||
);
|
||||
|
||||
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
|
||||
// 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。
|
||||
useEffect(() => {
|
||||
@@ -1147,6 +1180,8 @@ export default function RoundtablePlanningPage() {
|
||||
// 真正建好的窗口里,用它让历史记录显示「挂起中」(作业建好后转 queued/running,由后端状态接管)。
|
||||
// resetToStep1 不卸载本页,故该 state 跨「跳转」存活。
|
||||
const [pendingSuspendIds, setPendingSuspendIds] = useState<Set<string>>(() => new Set());
|
||||
// 整片脱敏:顶部页头(步骤导航 / 历史记录 / 标题等)的所有文本。
|
||||
const headerDzRef = useDesensitizeSubtree<HTMLElement>();
|
||||
const markPendingSuspend = useCallback((draftId: string) => {
|
||||
setPendingSuspendIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1446,7 +1481,7 @@ export default function RoundtablePlanningPage() {
|
||||
)}
|
||||
|
||||
{/* HEADER */}
|
||||
<header className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
|
||||
<header ref={headerDzRef} className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
|
||||
<StepNavigation
|
||||
currentStep={currentStep}
|
||||
intentReady={step1.intentReady}
|
||||
@@ -1557,6 +1592,9 @@ export default function RoundtablePlanningPage() {
|
||||
onSelectStep2Run={handleSelectStep2Run}
|
||||
seatFilter={seatFilter}
|
||||
setSeatFilter={setSeatFilter}
|
||||
selectedChainId={autoChainResult?.chain?.id ?? null}
|
||||
onSelectChain={handleSelectChainFromSidebar}
|
||||
onOpenDraft={drafts.handleLoadDraft}
|
||||
/>
|
||||
|
||||
{/* MIDDLE VIEWPORT */}
|
||||
@@ -2056,6 +2094,12 @@ interface LeftSidebarProps {
|
||||
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
|
||||
seatFilter: SeatFilter | null;
|
||||
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
|
||||
/** 需求 0616 #2:当前已勾选的业务链 id(高亮用),来自 autoChainResult.chain.id。 */
|
||||
selectedChainId: string | null;
|
||||
/** 需求 0616 #2:Step1 落地左栏「业务链」点击 → 勾选该链(不直接进研讨)。 */
|
||||
onSelectChain: (chain: BusinessChain) => void;
|
||||
/** 需求 0616 #2:Step1 落地左栏「分析记录」点击 → 打开历史会商分析(加载草稿)。 */
|
||||
onOpenDraft: (draftId: string) => void;
|
||||
}
|
||||
|
||||
function Step2SandboxSidebarSync({
|
||||
@@ -2097,7 +2141,13 @@ function LeftSidebar({
|
||||
onSelectStep2Run,
|
||||
seatFilter,
|
||||
setSeatFilter,
|
||||
selectedChainId,
|
||||
onSelectChain,
|
||||
onOpenDraft,
|
||||
}: LeftSidebarProps) {
|
||||
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
||||
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
||||
const dzRef = useDesensitizeSubtree<HTMLElement>();
|
||||
const isStep2 = currentStep === 2;
|
||||
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
|
||||
const toggleSeatFilter = (filter: SeatFilter) =>
|
||||
@@ -2109,6 +2159,7 @@ function LeftSidebar({
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={dzRef}
|
||||
id="left-sidebar"
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out",
|
||||
@@ -2223,28 +2274,147 @@ function LeftSidebar({
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<TaskOverviewCard currentStep={currentStep} />
|
||||
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
|
||||
</div>
|
||||
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
|
||||
{roles.map((role) => (
|
||||
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HumanParticipationCard />
|
||||
</>
|
||||
// Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
|
||||
<RoundtableEntryRail
|
||||
selectedChainId={selectedChainId}
|
||||
onSelectChain={onSelectChain}
|
||||
onOpenDraft={onOpenDraft}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 需求 0616 #2:多智能体会商 Step1(落地)左栏入口栏。
|
||||
* - 业务链:仅公共链条(``listChains("public")``)前 3 个;点击以该链席位启动会商。
|
||||
* - 分析记录:圆桌历史草稿(``useRoundtableDrafts``);点击打开对应历史会商分析。
|
||||
* 进入研讨(Step 2/3)后左栏恢复为「参与角色 / 任务概览」工作面板。
|
||||
*/
|
||||
function RoundtableEntryRail({
|
||||
selectedChainId,
|
||||
onSelectChain,
|
||||
onOpenDraft,
|
||||
}: {
|
||||
selectedChainId: string | null;
|
||||
onSelectChain: (chain: BusinessChain) => void;
|
||||
onOpenDraft: (draftId: string) => void;
|
||||
}) {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const { drafts: records, refresh } = useRoundtableDrafts();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void listChains("public")
|
||||
.then((all) => {
|
||||
if (!cancelled) setChains(all.slice(0, 3));
|
||||
})
|
||||
.catch(() => {
|
||||
/* 业务链非关键,静默忽略 */
|
||||
});
|
||||
void refresh();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const formatTime = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 业务链(公共,前 3 个) */}
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||
<Layers className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">业务链</h2>
|
||||
</div>
|
||||
{chains.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{chains.map((chain) => {
|
||||
const selected = selectedChainId === chain.id;
|
||||
return (
|
||||
<button
|
||||
key={chain.id}
|
||||
type="button"
|
||||
onClick={() => onSelectChain(chain)}
|
||||
title={selected ? "再次点击取消勾选" : `勾选业务链「${chain.title}」`}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"w-full rounded-xl border p-2.5 text-left transition-colors",
|
||||
selected
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-slate-100 bg-slate-50/60 hover:border-primary/30 hover:bg-white",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-xs font-extrabold",
|
||||
selected ? "text-primary" : "text-slate-800",
|
||||
)}
|
||||
>
|
||||
{chain.title}
|
||||
</span>
|
||||
{selected ? (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-semibold text-slate-400">
|
||||
{chain.seats.length} 个席位
|
||||
{selected ? " · 已选" : ""}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分析记录(圆桌历史草稿) */}
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[200px]">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||
<History className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">分析记录</h2>
|
||||
</div>
|
||||
{records.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 py-1.5">暂无分析记录</p>
|
||||
) : (
|
||||
<div className="space-y-2 overflow-y-auto flex-1 pr-1 custom-scrollbar">
|
||||
{records.map((rec) => (
|
||||
<button
|
||||
key={rec.id}
|
||||
type="button"
|
||||
onClick={() => onOpenDraft(rec.id)}
|
||||
title={rec.title}
|
||||
className="w-full rounded-xl border border-slate-100 bg-slate-50/60 p-2.5 text-left transition-colors hover:border-primary/20 hover:bg-white"
|
||||
>
|
||||
<div className="truncate text-xs font-bold text-slate-700">{rec.title}</div>
|
||||
<div className="mt-1 text-[10px] font-semibold text-slate-400">
|
||||
{formatTime(rec.updatedAt)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step2RunSwitcher({
|
||||
runs,
|
||||
activeRunId,
|
||||
@@ -2869,14 +3039,14 @@ function RoleAvatarBadge({
|
||||
);
|
||||
|
||||
if (!clickable) {
|
||||
return <div title={`${role.name} · ${role.status}`}>{inner}</div>;
|
||||
return <div title={`${desensitize(role.name)} · ${role.status}`}>{inner}</div>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={active}
|
||||
title={active ? "取消筛选,显示全部发言" : `只看 ${role.name} 的发言`}
|
||||
title={active ? "取消筛选,显示全部发言" : `只看 ${desensitize(role.name)} 的发言`}
|
||||
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
>
|
||||
{inner}
|
||||
@@ -2916,7 +3086,7 @@ function RoleRow({
|
||||
clickable
|
||||
? active
|
||||
? "取消筛选,显示全部发言"
|
||||
: `只看 ${role.name} 的发言`
|
||||
: `只看 ${desensitize(role.name)} 的发言`
|
||||
: undefined
|
||||
}
|
||||
onClick={onSelect}
|
||||
@@ -2946,7 +3116,7 @@ function RoleRow({
|
||||
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-bold leading-5 tracking-tight text-slate-800">
|
||||
{role.name}
|
||||
{desensitize(role.name)}
|
||||
</span>
|
||||
{role.type === "agent" && (
|
||||
<span className="text-[11px] text-primary font-medium leading-4">AI 智能体</span>
|
||||
@@ -3016,10 +3186,13 @@ function RightSidebar({
|
||||
triggerToast,
|
||||
onSaveIntent,
|
||||
}: RightSidebarProps) {
|
||||
// 整片脱敏:右侧面板(推荐模型 / 意图摘要 / 澄清记录)所有文本。
|
||||
const dzRef = useDesensitizeSubtree<HTMLElement>();
|
||||
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
|
||||
if (currentStep !== 1) return null;
|
||||
return (
|
||||
<section
|
||||
ref={dzRef}
|
||||
id="right-sidebar"
|
||||
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import { useMyPosition } from "@/core/positions";
|
||||
import {
|
||||
getActiveThirdLevelItems,
|
||||
type MenuItem,
|
||||
@@ -131,6 +132,8 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
const auth = getStoredAuth();
|
||||
const isAdmin = auth?.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const { data: myPosition } = useMyPosition();
|
||||
const positionName = myPosition?.name?.trim() || "";
|
||||
|
||||
// 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName);
|
||||
// 缺省回退到 stored auth 的邮箱前缀。
|
||||
@@ -278,6 +281,11 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<p className="text-sm leading-tight font-medium text-white">
|
||||
{displayName}
|
||||
</p>
|
||||
{positionName && (
|
||||
<p className="text-[11px] leading-tight text-white/70">
|
||||
{positionName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
|
||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user