- 欢迎使用「妙笔 AI」智力打字协作工坊
+ 欢迎使用「ai写作」智力打字协作工坊
这是一个专业的自动化写作系统。你只需在下方对话框中
diff --git a/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx b/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx
index a5bfbe8c..a05dc175 100644
--- a/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx
+++ b/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx
@@ -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(initialNotebookId ?? null);
+ const [selectedDocumentIds, setSelectedDocumentIds] = useState(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 {children};
diff --git a/frontend-web/src/open-canvas/hooks/useResearchTemplates.ts b/frontend-web/src/open-canvas/hooks/useResearchTemplates.ts
new file mode 100644
index 00000000..6ae76d28
--- /dev/null
+++ b/frontend-web/src/open-canvas/hooks/useResearchTemplates.ts
@@ -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([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(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 => {
+ 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 => {
+ 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 => {
+ await deleteResearchTemplate(id);
+ setTemplates((prev) => prev.filter((t) => t.id !== id));
+ }, []);
+
+ return { templates, loading, error, refresh, create, update, remove };
+}
diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
index 2e86de1c..c403bb45 100644
--- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
+++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
@@ -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(null);
+ const overlayActive = rightView != null;
+ const viewingNotebookId = rightView?.kind === 'notebook' ? rightView.id : null;
+
+ // 进入写作(开始生成 / 查看历史)后收起研究模板侧栏;嵌入态(studioToken)始终不展示。
+ const showSidebar = !studioToken && state.status === 'idle' && !isHistoryMode;
+
+ return (
+
+ {/* 需求 0616 #5:课题研究左栏(研究模板 + 笔记本)。进入写作后收起。 */}
+ {showSidebar && (
+
setRightView({ kind: 'notebook', id })}
+ onOpenAllSpaces={() => setRightView({ kind: 'allSpaces' })}
+ />
+ )}
+ {/* 右侧:写作布局常驻(仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程),
+ 查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
+
+
+ {overlayActive && (
+
+
+
+ {rightView.kind === 'notebook' && (
+
+ )}
+
+
+ {rightView.kind === 'notebook' ? (
+ // 笔记本详情页内部用 ChatBox(useArtifacts) → 需 ChatRuntime 提供 Provider。
+
+
+
+ ) : (
+
+ setRightView({ kind: 'notebook', id })}
+ />
+
+ )}
+
+
+ )}
+
+
+ );
+}
+
export default function AIWritingPage({
hideAdvancedFields = false,
initialNotebookId,
@@ -227,12 +309,19 @@ export default function AIWritingPage({
-
+
diff --git a/frontend-web/src/open-canvas/pages/ResearchTemplateEditorPage.tsx b/frontend-web/src/open-canvas/pages/ResearchTemplateEditorPage.tsx
new file mode 100644
index 00000000..adcdf976
--- /dev/null
+++ b/frontend-web/src/open-canvas/pages/ResearchTemplateEditorPage.tsx
@@ -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(EMPTY_FORM);
+ // 新建即就绪;编辑需等列表加载后回填一次。
+ const [initialized, setInitialized] = useState(!editing);
+ const [formError, setFormError] = useState(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 = (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 (
+
+
+
+
+ {/* 左:模板助手问答(同 AI 写作 setup 机制)→ 应用到右侧表单。问答区更宽(约 7:3)。 */}
+
+
+
+
+ {/* 右:模板表单 */}
+
+
+
+ set('name', e.target.value)}
+ placeholder="如:深度舆情分析"
+ />
+
+
+
+ set('description', e.target.value)}
+ placeholder="模板用途简述"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ set('word_count', e.target.value)}
+ />
+
+
+
+ set('audience', e.target.value)}
+ placeholder="如:决策者"
+ />
+
+
+
+
+
+
+
+
+ set('strict_mode', e.target.checked)}
+ className="size-4"
+ />
+
+
+
+
+ set('sort_order', e.target.value)}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/open-canvas/pages/ResearchTemplatesConfigPage.tsx b/frontend-web/src/open-canvas/pages/ResearchTemplatesConfigPage.tsx
new file mode 100644
index 00000000..ad9ebfa1
--- /dev/null
+++ b/frontend-web/src/open-canvas/pages/ResearchTemplatesConfigPage.tsx
@@ -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(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 (
+
+
+
+
+
+
研究模板配置
+
+ 配置课题研究左栏的研究模板;点击模板时一键预填写作表单。
+
+
+
+
+
+
+ {error &&
{error}
}
+
+ {loading ? (
+
+
+
+ ) : templates.length === 0 ? (
+
+ 暂无研究模板,点击右上角「新建模板」创建。
+
+ ) : (
+
+ {templates.map((t) => (
+
+
+
+
+
+
+ {t.name}
+
+ {articleTypeLabel(t.article_type_key)}
+
+
+ {t.description && (
+
{t.description}
+ )}
+
+ 来源:{t.material_source === 'notebook' ? '我的空间' : '通用检索'}
+ 字数:{t.word_count}
+ {t.audience && 受众:{t.audience}}
+ {t.strict_mode ? '严格模式' : '宽松模式'}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* 删除确认 */}
+
+
+ );
+}
diff --git a/frontend-web/src/open-canvas/routes/OpenCanvasRoutes.tsx b/frontend-web/src/open-canvas/routes/OpenCanvasRoutes.tsx
index d1685e6e..3de029ba 100644
--- a/frontend-web/src/open-canvas/routes/OpenCanvasRoutes.tsx
+++ b/frontend-web/src/open-canvas/routes/OpenCanvasRoutes.tsx
@@ -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() {
} />
} />
} />
+ } />
+ } />
+ } />
} />
);
diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx
index 33626595..fc485f41 100644
--- a/frontend-web/src/pages/AgentChatPage.tsx
+++ b/frontend-web/src/pages/AgentChatPage.tsx
@@ -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: [] })}
/>
diff --git a/frontend-web/src/pages/AgentModulePage.tsx b/frontend-web/src/pages/AgentModulePage.tsx
new file mode 100644
index 00000000..11f371a5
--- /dev/null
+++ b/frontend-web/src/pages/AgentModulePage.tsx
@@ -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 (
+
+
+ } />
+ }
+ />
+
+
+ );
+}
+
+/** 进入模块默认落地:配置类型下的第一个智能体;无配置回退到模块默认体。 */
+function ModuleDefaultRedirect({ module }: { module: AgentModuleConfig }) {
+ const { agents, isLoading: agentsLoading } = useAgents();
+ const { data: config, isLoading: configLoading } = useAgentModuleConfig(
+ module.key,
+ );
+
+ if (agentsLoading || configLoading) {
+ return (
+
+
+
+ );
+ }
+
+ 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 ;
+}
+
+/** 模块外壳:常驻左栏 + 内嵌当前智能体对话(按 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 (
+
+
setSettingsOpen(true) : undefined}
+ />
+
+ {isAdmin && (
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/pages/BusinessChainMappingPage.tsx b/frontend-web/src/pages/BusinessChainMappingPage.tsx
index 291f3cdd..7efffe08 100644
--- a/frontend-web/src/pages/BusinessChainMappingPage.tsx
+++ b/frontend-web/src/pages/BusinessChainMappingPage.tsx
@@ -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();
const [rows, setRows] = useState([]);
const [agents, setAgents] = useState([]);
@@ -160,7 +163,7 @@ export default function BusinessChainMappingPage() {
];
return (
-
+
{/* ── 页头 banner ── */}
diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx
index 930dac06..81f21b66 100644
--- a/frontend-web/src/pages/ChatPage.tsx
+++ b/frontend-web/src/pages/ChatPage.tsx
@@ -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(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: [] })
diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx
index f9983498..21422426 100644
--- a/frontend-web/src/pages/IframeChatPage.tsx
+++ b/frontend-web/src/pages/IframeChatPage.tsx
@@ -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() {
-
- {modeButtons.map((button) => {
- const Icon = button.icon;
- return (
-
- );
- })}
-
+
+
+ {modeButtons.map((button) => {
+ const Icon = button.icon;
+ return (
+
+
+
+ );
+ })}
+
+
diff --git a/frontend-web/src/pages/KnowledgeBasePage.tsx b/frontend-web/src/pages/KnowledgeBasePage.tsx
index b12cfb5f..5380610c 100644
--- a/frontend-web/src/pages/KnowledgeBasePage.tsx
+++ b/frontend-web/src/pages/KnowledgeBasePage.tsx
@@ -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(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)}
/>
+