diff --git a/CLAUDE.md b/CLAUDE.md index 7dba5283..7e0bd149 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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}/taskAnalysSearch/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`. 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 diff --git a/frontend-web/.env.development b/frontend-web/.env.development index d5dfe0f9..5ea0eb44 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -27,7 +27,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 diff --git a/frontend-web/src/components/workspace/agents/agent-module-settings-dialog.tsx b/frontend-web/src/components/workspace/agents/agent-module-settings-dialog.tsx new file mode 100644 index 00000000..b1e71d7e --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-module-settings-dialog.tsx @@ -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(); + // Radix Dialog(modal) 把 body 设为 pointer-events:none,TDesign 浮层默认挂到 body 上 + // 会点不动 —— 用 attach 把下拉浮层挂到弹窗内部容器(pointer-events 正常)。 + const selectWrapRef = useRef(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(() => { + 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 ( + + + + {moduleTitle} · 类型配置 + + 选择该模块左栏展示的智能体类型(标签);下方预览该类型下的智能体。 + + + +
+
+
+ 选择类型 +
+ {tags.length === 0 ? ( +

+ 暂无智能体标签,请先到「标签管理」创建。 +

+ ) : ( +
+ setQuery(e.target.value)} + placeholder="搜索历史会话…" + className="h-8 rounded-lg border-border bg-card pl-8 text-[12px]" + /> +
+ +
+ {filteredThreads.length === 0 ? ( +
+ {query ? "没有匹配的会话" : t.chats.noChats} +
+ ) : ( + filteredThreads.map((thread) => { + const isActive = pathOfThread(thread) === pathname; + const pinned = isThreadPinned(thread); + const favorited = isThreadFavorite(thread); + return ( +
+ + + {pinned && ( + + )} + + {desensitize(titleOfThread(thread, t.pages.newChat))} + + {isActive && ( + + )} + + {!isStatic && ( + + + + + + + handleRenameClick( + thread.thread_id, + titleOfThread(thread, t.pages.newChat), + ) + } + > + + {t.common.rename} + + togglePin(thread.thread_id, !pinned)}> + {pinned ? ( + + ) : ( + + )} + {pinned ? t.common.unpin : t.common.pin} + + toggleFavorite(thread.thread_id, !favorited)} + > + + {favorited ? t.common.unfavorite : t.common.favorite} + + setShareThreadId(thread.thread_id)}> + + {t.common.share} + + + + + {t.common.export} + + + handleExport(thread, "markdown")}> + + {t.common.exportAsMarkdown} + + handleExport(thread, "json")}> + + {t.common.exportAsJSON} + + + + + handleDelete(thread.thread_id)}> + + {t.common.delete} + + + + )} +
+ ); + }) + )} + {!query && ( + + 更多最近对话 + + + )} +
+
+ )} +
+ + + {/* 重命名弹窗 */} + + + + {t.common.rename} + +
+ setRenameValue(e.target.value)} + placeholder={t.common.rename} + onKeyDown={(e) => { + if (e.key === "Enter" && !isIMEComposing(e)) { + e.preventDefault(); + handleRenameSubmit(); + } + }} + /> +
+ + + + +
+
+ + {/* 分享弹窗 */} + { + if (!o) setShareThreadId(null); + }} + /> + + ); +} diff --git a/frontend-web/src/components/workspace/workspace-sidebar.tsx b/frontend-web/src/components/workspace/workspace-sidebar.tsx index de7afebd..39206d90 100644 --- a/frontend-web/src/components/workspace/workspace-sidebar.tsx +++ b/frontend-web/src/components/workspace/workspace-sidebar.tsx @@ -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} > - - {isGeneralQa && } - + {isGeneralQa ? ( + // 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成 + // GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。 + + ) : ( + <> + + + + )} diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts new file mode 100644 index 00000000..c9289b79 --- /dev/null +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -0,0 +1,161 @@ +/** + * 外部系统跳转登录时的「任务深链」:登录 URL 带 ``taskId`` + ``goPath`` 时, + * 拉取任务详情并据 goPath 把用户直接送到对应的智能体问答 / 多智能体会商页。 + * + * 流程: + * 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalysSearch/cop-task-detail?taskId=``, + * 请求头 ``Authorization: Bearer admin``(由 service 拦截器统一注入)。 + * 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent)整包存到 sessionStorage。 + * 3. 按 goPath 解析出业务码 + 模式: + * - ``3qfx`` / ``rwfx`` / ``xdfx`` → 单智能体问答(用「配置管理」里该业务配置的智能体) + * - ``3qfx-A`` / ``rwfx-A`` / ``xdfx-A`` → 多智能体会商(用该业务配置的业务链条) + * 携带 taskName / taskContent 作为开场信息。 + * + * 业务码映射与「配置管理」(`/api/business-mapping`) 一致: + * 3Q=三情分析(3QFX)、6BF=任务分析(任务FX)、7BF=行动分析(XDFX)。 + */ + +import { requestCrudService } from "@/service/request-axios-service"; +import { listBusinessMappings } from "@/strategy-components/api/business-mapping"; + +/** consumer 接口返回的任务详情(data 字段)。 */ +export interface CopTaskDetail { + id: string | number | null; + overview: string; + taskDirection: string; + taskName: string; + taskContent: string; +} + +/** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */ +export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail"; + +/** goPath 基础码 → 业务码(配置管理 businessCode)。 */ +const GOPATH_BUSINESS_CODE: Record = { + "3qfx": "3Q", // 三情分析 + rwfx: "6BF", // 任务分析(任务FX) + xdfx: "7BF", // 行动分析(XDFX) +}; + +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 { + const base = consumerBaseUrl(); + if (!base || !taskId) return null; + // service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。 + const body = await requestCrudService.get(`${base}/taskAnalysSearch/cop-task-detail`, { taskId }); + const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null; + if (!data || typeof data !== "object") return null; + const d = data as Record; + 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; +} + +/** 用 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 { + const parsed = parseGoPath(goPath); + if (!parsed) return null; + + if (parsed.roundtable) { + return { + path: "/page/strategy/qa/multi-agent-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: `/page/strategy/agents/${agentId}/chats/new` }; +} diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 25f2a51f..161ba0b9 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -67,9 +67,9 @@ export const sidebarMenuItems: MenuItem[] = [ id: "qa-zzjc", label: "zzJC支持", 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: "zzXD应用", icon: Layout, path: "/page/strategy/qa/zzxd" }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, ], }, diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 20fd57f1..569ecad8 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -247,6 +247,45 @@ export async function updateCitationDisplaySettings( return (await response.json()) as CitationDisplaySettings; } +/** 「按类型聚合」模块(zzJC / zzXD)选中的智能体标签配置(需求 0616 #3/#4)。 */ +export interface AgentModuleConfig { + key: string; + tag_ids: string[]; +} + +export async function loadAgentModuleConfig( + key: string, +): Promise { + 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 { + 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; diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 05bf91a0..c7d4be11 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -3,12 +3,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useLocalSettings } from "@/core/settings"; import { + loadAgentModuleConfig, loadAppearanceSettings, loadCitationDisplaySettings, loadLeaderboardSettings, loadPrivateSquareStatus, loadPromptPrefixSettings, loadPublishSquares, + updateAgentModuleConfig, updateAppearanceSettings, updateCitationDisplaySettings, updateLeaderboardSettings, @@ -165,6 +167,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, diff --git a/frontend-web/src/lib/desensitize.ts b/frontend-web/src/lib/desensitize.ts index 3778cc98..a6e6bd0f 100644 --- a/frontend-web/src/lib/desensitize.ts +++ b/frontend-web/src/lib/desensitize.ts @@ -4,6 +4,9 @@ * value: 脱敏后替换词 */ export const DESENSITIZE_MAP: Record = { + 方案: "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(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 = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = desensitizeDeep(v); + } + return out as T; + } + return value; +} + /** * 运行时动态追加脱敏词,追加后自动重编译正则。 * diff --git a/frontend-web/src/open-canvas/api/ai-writing-research-templates.ts b/frontend-web/src/open-canvas/api/ai-writing-research-templates.ts new file mode 100644 index 00000000..05d9e1a8 --- /dev/null +++ b/frontend-web/src/open-canvas/api/ai-writing-research-templates.ts @@ -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; + +const BASE = () => `${getBackendBaseURL()}/api/ai-writing/research-templates`; + +export async function listResearchTemplates(): Promise { + const resp = await apiFetch(BASE()); + if (!resp.ok) throw new Error(`获取研究模板失败: ${resp.status}`); + return resp.json(); +} + +export async function createResearchTemplate( + data: CreateResearchTemplateRequest, +): Promise { + 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 { + 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 { + const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' }); + if (resp.status === 404) throw new Error(`研究模板不存在`); + if (!resp.ok) throw new Error(`删除研究模板失败: ${resp.status}`); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index e6ca5d91..aa6a7d60 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -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( - initialNotebookId ?? null, - ); - const [selectedDocumentIds, setSelectedDocumentIds] = useState(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], ); } diff --git a/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx b/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx new file mode 100644 index 00000000..d464b872 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/ResearchSidebar.tsx @@ -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(["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 ( + + ); +} 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..8df94935 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'; @@ -221,17 +228,78 @@ export default function AIWritingPage({ initialDocumentIds?: number[]; studioToken?: string; } = {}) { + // 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容 + // (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。 + const [rightView, setRightView] = useState(null); + const overlayActive = rightView != null; + const viewingNotebookId = rightView?.kind === 'notebook' ? rightView.id : null; + return ( - +
-
- +
+ {/* 需求 0616 #5:课题研究左栏(研究模板 + 笔记本)。studio 嵌入态(studioToken)隐藏。 */} + {!studioToken && ( + 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 })} + /> +
+ )} +
+
+ )} +
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..94da2258 --- /dev/null +++ b/frontend-web/src/open-canvas/pages/ResearchTemplatesConfigPage.tsx @@ -0,0 +1,374 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft, FileText, Loader2, Pencil, Plus, Trash2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +import { useResearchTemplates } from '../hooks/useResearchTemplates'; +import { useArticleTypes } from '../hooks/useArticleTypes'; +import type { + CreateResearchTemplateRequest, + ResearchTemplate, +} from '../api/ai-writing-research-templates'; + +const INPUT_CLASS = + 'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ' + + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50'; +const TEXTAREA_CLASS = + 'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ' + + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none'; +const LABEL_CLASS = 'mb-1 block text-xs font-medium text-muted-foreground'; + +interface FormState { + name: string; + description: string; + article_type_key: string; + outline: string; + material_source: 'general' | 'notebook'; + word_count: string; + audience: string; + strict_mode: boolean; + topic: string; + sort_order: string; +} + +const EMPTY_FORM: FormState = { + name: '', + description: '', + article_type_key: '', + outline: '', + material_source: 'general', + word_count: '800', + audience: '', + strict_mode: false, + topic: '', + sort_order: '0', +}; + +function toForm(t: ResearchTemplate): FormState { + return { + name: t.name, + description: t.description ?? '', + article_type_key: t.article_type_key ?? '', + outline: t.outline ?? '', + material_source: t.material_source, + word_count: String(t.word_count), + audience: t.audience ?? '', + strict_mode: t.strict_mode, + topic: t.topic ?? '', + sort_order: String(t.sort_order), + }; +} + +function toPayload(form: FormState): CreateResearchTemplateRequest { + return { + name: form.name.trim(), + description: form.description.trim() || null, + article_type_key: form.article_type_key || null, + outline: form.outline.trim() || null, + material_source: form.material_source, + word_count: Math.max(100, parseInt(form.word_count, 10) || 800), + audience: form.audience.trim() || null, + strict_mode: form.strict_mode, + topic: form.topic.trim() || null, + sort_order: parseInt(form.sort_order, 10) || 0, + }; +} + +export default function ResearchTemplatesConfigPage() { + const navigate = useNavigate(); + const { templates, loading, error, refresh, create, update, remove } = useResearchTemplates(); + const { articleTypes, refresh: refreshTypes } = useArticleTypes(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + const [deleteTargetId, setDeleteTargetId] = useState(null); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + void refresh(); + void refreshTypes(); + }, [refresh, refreshTypes]); + + const set = (key: K, value: FormState[K]) => + setForm((f) => ({ ...f, [key]: value })); + + const openCreate = () => { + setEditingId(null); + setForm(EMPTY_FORM); + setFormError(null); + setDialogOpen(true); + }; + + const openEdit = (t: ResearchTemplate) => { + setEditingId(t.id); + setForm(toForm(t)); + setFormError(null); + setDialogOpen(true); + }; + + const handleSave = async () => { + if (!form.name.trim()) { + setFormError('请填写模板名称'); + return; + } + setSaving(true); + setFormError(null); + try { + const payload = toPayload(form); + if (editingId) await update(editingId, payload); + else await create(payload); + setDialogOpen(false); + } catch (e) { + setFormError(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!deleteTargetId) return; + setDeleting(true); + try { + await remove(deleteTargetId); + setDeleteTargetId(null); + } finally { + setDeleting(false); + } + }; + + const articleTypeLabel = (key: string | null) => + key ? articleTypes.find((t) => t.key === key)?.label ?? key : '—'; + + return ( +
+
+
+ +
+

研究模板配置

+

+ 配置课题研究左栏的研究模板;点击模板时一键预填写作表单。 +

+
+
+ +
+ + {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 ? '严格模式' : '宽松模式'} +
+
+
+ + +
+
+ ))} +
+ )} + + {/* 新建 / 编辑 弹窗 */} + !saving && setDialogOpen(v)}> + + + {editingId ? '编辑研究模板' : '新建研究模板'} + +
+
+ + set('name', e.target.value)} + placeholder="如:深度舆情分析" + /> +
+
+ + set('description', e.target.value)} + placeholder="模板用途简述" + /> +
+
+ + +
+
+ + +
+
+ + set('word_count', e.target.value)} + /> +
+
+ + set('audience', e.target.value)} + placeholder="如:决策者" + /> +
+
+ +