diff --git a/frontend-web/.env.network b/frontend-web/.env.network index ae615eb8..d81d70e5 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -14,8 +14,8 @@ VITE_MOCK_SQ_REPORT=false 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 +VITE_BACKEND_BASE_URL=http://10.19.200.5:2026 +VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 diff --git a/frontend-web/src/components/ai-elements/code-block.tsx b/frontend-web/src/components/ai-elements/code-block.tsx index d490eeda..bce6fe3e 100644 --- a/frontend-web/src/components/ai-elements/code-block.tsx +++ b/frontend-web/src/components/ai-elements/code-block.tsx @@ -1,7 +1,8 @@ "use client"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { copyTextToClipboard } from "@/core/clipboard"; +import { cn } from "@/lib/utils"; import { CheckIcon, CopyIcon } from "lucide-react"; import { type ComponentProps, @@ -144,18 +145,13 @@ export const CodeBlockCopyButton = ({ ...props }: CodeBlockCopyButtonProps) => { const [isCopied, setIsCopied] = useState(false); - const { code } = useContext(CodeBlockContext); - - const copyToClipboard = async () => { - if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { - onError?.(new Error("Clipboard API not available")); - return; - } - - try { - await navigator.clipboard.writeText(code); - setIsCopied(true); - onCopy?.(); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = async () => { + try { + await copyTextToClipboard(code); + setIsCopied(true); + onCopy?.(); setTimeout(() => setIsCopied(false), timeout); } catch (error) { onError?.(error as Error); diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index dcd6d382..40ab6267 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -7,6 +7,7 @@ import { ChevronsUpDown, Folder, FolderOpen, + Gauge, ListTree, MousePointerClick, Settings, @@ -231,6 +232,14 @@ function SecondaryNavSidebar() { 白名单管理 )} + {isAdmin && ( + navigate("/page/strategy/settings/model-concurrency")} + > + + 模型并发限制 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")} diff --git a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx index 65b504f0..d0bf5763 100644 --- a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx +++ b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx @@ -260,37 +260,68 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string }) key={agent.id} href={href} className={cn( - "group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors", + "group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 transition-all duration-150", isActive - ? "border-primary/40 bg-primary/5" - : "border-transparent hover:border-border hover:bg-accent/50", + ? isDark + ? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]" + : "border-primary/50 bg-primary/10 shadow-sm" + : isDark + ? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60" + : "border-transparent hover:border-border hover:bg-accent/50", )} > - + {desensitize(agent.name)} - + {agent.description ? desensitize(agent.description) : "智能体助手"} {isActive && ( - + )} ); diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index e36087c8..2338e457 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -138,6 +138,7 @@ export function AgentDetailPanel({ disabled, defaultCollapsed, showRecentChats, + compactAppearanceActions, }: { agent: Agent | null | undefined; agentName: string; @@ -147,6 +148,8 @@ export function AgentDetailPanel({ defaultCollapsed?: boolean; /** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */ showRecentChats?: boolean; + /** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */ + compactAppearanceActions?: ReactNode; }) { const router = useRouter(); const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); @@ -830,6 +833,7 @@ export function AgentDetailPanel({ 智能体信息
{headerActions} + {compactAppearanceActions} {collapseButton}
@@ -841,17 +845,20 @@ export function AgentDetailPanel({ <> {/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */} {collapsed ? ( -
- - - +
+
+ {compactAppearanceActions} + + + +
) : (
- ))} - - - ); -} + + + + ); +} function SandboxPreviewImage({ src, diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx index 4e83c9d6..fc418584 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx @@ -1,6 +1,6 @@ -import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react"; -import { useCallback, useState } from "react"; -import { toast } from "sonner"; +import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -10,18 +10,18 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { downloadArtifactFile } from "@/core/artifacts/download"; -import { useI18n } from "@/core/i18n/hooks"; -import { installSkill } from "@/core/skills/api"; -import { - getFileExtensionDisplayName, - getFileIcon, - getFileName, -} from "@/core/utils/files"; +import { downloadArtifactFile } from "@/core/artifacts/download"; +import { useI18n } from "@/core/i18n/hooks"; +import { + getFileExtensionDisplayName, + getFileIcon, + getFileName, +} from "@/core/utils/files"; import { cn } from "@/lib/utils"; -import { useThread } from "../messages/context"; -import { useArtifacts } from "./context"; +import { useThread } from "../messages/context"; +import { useArtifacts } from "./context"; +import { SkillInstallDialog } from "./skill-install-dialog"; export function ArtifactFileList({ className, @@ -32,11 +32,11 @@ export function ArtifactFileList({ files: string[]; threadId: string; }) { - const { t } = useI18n(); - const { isMock, publicShareCode } = useThread(); - const { select: selectArtifact, setOpen } = useArtifacts(); - const [installingFile, setInstallingFile] = useState(null); - const [downloadingFile, setDownloadingFile] = useState(null); + const { t } = useI18n(); + const { isMock, publicShareCode } = useThread(); + const { select: selectArtifact, setOpen } = useArtifacts(); + const [installFile, setInstallFile] = useState(null); + const [downloadingFile, setDownloadingFile] = useState(null); const handleClick = useCallback( (filepath: string) => { @@ -46,33 +46,14 @@ export function ArtifactFileList({ [selectArtifact, setOpen], ); - const handleInstallSkill = useCallback( - async (e: React.MouseEvent, filepath: string) => { - e.stopPropagation(); - e.preventDefault(); - - if (installingFile) return; - - setInstallingFile(filepath); - try { - const result = await installSkill({ - thread_id: threadId, - path: filepath, - }); - if (result.success) { - toast.success(result.message); - } else { - toast.error(result.message || "Failed to install skill"); - } - } catch (error) { - console.error("Failed to install skill:", error); - toast.error("Failed to install skill"); - } finally { - setInstallingFile(null); - } - }, - [threadId, installingFile], - ); + const handleInstallSkill = useCallback( + (e: React.MouseEvent, filepath: string) => { + e.stopPropagation(); + e.preventDefault(); + setInstallFile(filepath); + }, + [], + ); const handleDownload = useCallback( async (e: React.MouseEvent, filepath: string) => { @@ -113,20 +94,15 @@ export function ArtifactFileList({ {getFileExtensionDisplayName(file)} file - {file.endsWith(".skill") && ( - - )} + {file.endsWith(".skill") && ( + + )} + + + + + ); +} diff --git a/frontend-web/src/components/workspace/copy-button.tsx b/frontend-web/src/components/workspace/copy-button.tsx index 8aea3099..cce836c4 100644 --- a/frontend-web/src/components/workspace/copy-button.tsx +++ b/frontend-web/src/components/workspace/copy-button.tsx @@ -1,8 +1,10 @@ -import { CheckIcon, CopyIcon } from "lucide-react"; -import { useCallback, useState, type ComponentProps } from "react"; - -import { Button } from "@/components/ui/button"; -import { useI18n } from "@/core/i18n/hooks"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useCallback, useState, type ComponentProps } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { copyTextToClipboard } from "@/core/clipboard"; +import { useI18n } from "@/core/i18n/hooks"; import { Tooltip } from "./tooltip"; @@ -12,14 +14,18 @@ export function CopyButton({ }: ComponentProps & { clipboardData: string; }) { - const { t } = useI18n(); - const [copied, setCopied] = useState(false); - const handleCopy = useCallback(() => { - void navigator.clipboard.writeText(clipboardData); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [clipboardData]); - return ( + const { t } = useI18n(); + const [copied, setCopied] = useState(false); + const handleCopy = useCallback(async () => { + try { + await copyTextToClipboard(clipboardData); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error(t.clipboard.failedToCopyToClipboard); + } + }, [clipboardData, t.clipboard.failedToCopyToClipboard]); + return ( ); diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index d94f8db1..162d710b 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -1,16 +1,30 @@ "use client"; -import { DropletIcon, LayoutGridIcon, LayoutIcon, ListIcon, MoonIcon, PanelLeftIcon, SunIcon } from "lucide-react"; -import { useMemo, useState, type ComponentType, type SVGProps } from "react"; +import { + ArrowDown, + ArrowUp, + DropletIcon, + ExternalLink, + LayoutGridIcon, + LayoutIcon, + ListIcon, + MoonIcon, + PanelLeftIcon, + Plus, + SunIcon, + Trash2, +} from "lucide-react"; +import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useTheme } from "@/contexts/ThemeContext"; -import { FONT_SCALE_OPTIONS } from "@/components/font-scale"; -import { Button } from "@/components/ui/button"; -import { - Select, +import { FONT_SCALE_OPTIONS } from "@/components/font-scale"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, SelectContent, SelectItem, SelectTrigger, @@ -25,14 +39,24 @@ import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-sessio import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n"; import { useI18n } from "@/core/i18n/hooks"; import { useLocalSettings } from "@/core/settings"; -import { - useAppearanceSettings, +import { + useAppearanceSettings, useCitationDisplaySettings, useLayoutMode, useUpdateAppearanceSettings, useUpdateCitationDisplaySettings, -} from "@/core/system-settings/hooks"; -import { cn } from "@/lib/utils"; +} from "@/core/system-settings/hooks"; +import type { + CompactShortcutButton, + CompactShortcutRouteParam, +} from "@/core/system-settings/api"; +import { cn } from "@/lib/utils"; +import { + APP_THEME_COLORS, + LOGIN_SOURCE_OPTIONS, + parseThemeMap, + stringifyThemeMap, +} from "@/strategy-components/lib/light-app"; import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage"; import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage"; @@ -41,10 +65,101 @@ import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage"; import { SettingsSection } from "./settings-section"; -const languageOptions: { value: Locale; label: string }[] = [ - { value: "en-US", label: enUS.locale.localName }, - { value: "zh-CN", label: zhCN.locale.localName }, -]; +const languageOptions: { value: Locale; label: string }[] = [ + { value: "en-US", label: enUS.locale.localName }, + { value: "zh-CN", label: zhCN.locale.localName }, +]; + +const DEFAULT_SHORTCUT_THEME_MAP = "red=red;blue=blue;dark-blue=dark-blue"; + +function makeShortcutId() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function newShortcutParam(): CompactShortcutRouteParam { + return { + key: "access_token", + paramType: "login", + source: "access_token", + customValue: "", + themeValue: DEFAULT_SHORTCUT_THEME_MAP, + }; +} + +function newShortcutButton(): CompactShortcutButton { + return { + id: makeShortcutId(), + label: "快捷入口", + url: "", + route_params: [newShortcutParam()], + enabled: true, + sort_order: 0, + }; +} + +function normalizeShortcutDraft( + buttons: CompactShortcutButton[] | undefined, +): CompactShortcutButton[] { + const source = buttons?.length ? buttons : [newShortcutButton()]; + return [...source] + .sort((a, b) => a.sort_order - b.sort_order) + .map((button, index) => ({ + ...button, + id: button.id || makeShortcutId(), + label: button.label || "快捷入口", + url: button.url || "", + route_params: (button.route_params?.length + ? button.route_params + : [newShortcutParam()] + ).map((param) => ({ + key: param.key || "", + paramType: param.paramType || "login", + source: param.source || "access_token", + customValue: param.customValue ?? "", + themeValue: param.themeValue || DEFAULT_SHORTCUT_THEME_MAP, + })), + enabled: button.enabled ?? true, + sort_order: index, + })); +} + +function cleanShortcutParams( + params: CompactShortcutRouteParam[], +): CompactShortcutRouteParam[] { + return params + .filter((param) => param.key.trim()) + .map((param) => { + const base: CompactShortcutRouteParam = { + key: param.key.trim(), + paramType: param.paramType, + }; + if (param.paramType === "theme") { + base.themeValue = stringifyThemeMap(parseThemeMap(param.themeValue)); + return base; + } + base.source = param.source || "access_token"; + if (base.source === "other") { + base.customValue = (param.customValue ?? "").trim(); + } + return base; + }); +} + +function cleanShortcutButtons( + buttons: CompactShortcutButton[], +): CompactShortcutButton[] { + return buttons.map((button, index) => ({ + id: button.id || makeShortcutId(), + label: button.label.trim() || "快捷入口", + url: button.url.trim(), + route_params: cleanShortcutParams(button.route_params), + enabled: button.enabled, + sort_order: index, + })); +} export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { const { t, locale, changeLocale } = useI18n(); @@ -52,10 +167,11 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { const navigate = useNavigate(); const currentTheme = theme; const [settings, setSettings] = useLocalSettings(); - const isAdmin = getStoredAuth()?.system_role === "admin"; - const { data: appearance } = useAppearanceSettings(); - const updateAppearance = useUpdateAppearanceSettings(); - const { data: citationDisplay } = useCitationDisplaySettings(); + const isAdmin = getStoredAuth()?.system_role === "admin"; + const { data: appearance } = useAppearanceSettings(); + const updateAppearance = useUpdateAppearanceSettings(); + const [shortcutDraft, setShortcutDraft] = useState([]); + const { data: citationDisplay } = useCitationDisplaySettings(); const updateCitationDisplay = useUpdateCitationDisplaySettings(); const globalDefault = appearance?.default_theme ?? "light"; const globalSkillView = appearance?.default_skill_view ?? "list"; @@ -63,8 +179,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { const globalLayoutMode = appearance?.default_layout_mode ?? "full"; // 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。 const [effectiveLayoutMode, setLayoutMode] = useLayoutMode(); - const cleanupWords = - citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; + const cleanupWords = + citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; + + useEffect(() => { + setShortcutDraft( + normalizeShortcutDraft(appearance?.compact_shortcut_buttons), + ); + }, [appearance?.compact_shortcut_buttons]); // 独立登录会话(本标签页):开关切换鉴权存储 localStorage ↔ sessionStorage(见 scoped-storage)。 const [isolatedSession, setIsolatedSessionState] = useState(() => isIsolatedSession()); @@ -115,7 +237,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { } }; - const handleSetDefaultView = async ( + const handleSetDefaultView = async ( kind: "skill" | "agent", value: "card" | "list", ) => { @@ -134,7 +256,103 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { } catch (e) { toast.error(e instanceof Error ? e.message : "保存失败"); } - }; + }; + + const patchShortcut = ( + id: string, + patch: Partial, + ) => { + setShortcutDraft((items) => + items.map((item) => (item.id === id ? { ...item, ...patch } : item)), + ); + }; + + const patchShortcutParam = ( + id: string, + index: number, + patch: Partial, + ) => { + setShortcutDraft((items) => + items.map((item) => + item.id === id + ? { + ...item, + route_params: item.route_params.map((param, paramIndex) => + paramIndex === index ? { ...param, ...patch } : param, + ), + } + : item, + ), + ); + }; + + const moveShortcut = (id: string, direction: -1 | 1) => { + setShortcutDraft((items) => { + const index = items.findIndex((item) => item.id === id); + const target = index + direction; + if (index < 0 || target < 0 || target >= items.length) return items; + const next = [...items]; + [next[index], next[target]] = [next[target]!, next[index]!]; + return next.map((item, order) => ({ ...item, sort_order: order })); + }); + }; + + const addShortcutParam = (id: string) => { + setShortcutDraft((items) => + items.map((item) => + item.id === id + ? { + ...item, + route_params: [...item.route_params, newShortcutParam()], + } + : item, + ), + ); + }; + + const removeShortcutParam = (id: string, index: number) => { + setShortcutDraft((items) => + items.map((item) => + item.id === id + ? { + ...item, + route_params: item.route_params.filter((_, paramIndex) => paramIndex !== index), + } + : item, + ), + ); + }; + + const handleSaveShortcuts = async () => { + for (const item of shortcutDraft) { + if (item.enabled && !item.url.trim()) { + toast.error(`请填写「${item.label.trim() || "快捷入口"}」的跳转链接,或先关闭该按钮`); + return; + } + for (const param of item.route_params) { + if (!param.key.trim()) { + toast.error("路由参数键名不可为空"); + return; + } + if ( + param.paramType === "login" && + param.source === "other" && + !param.customValue?.trim() + ) { + toast.error("登录参数自定义值不可为空"); + return; + } + } + } + try { + await updateAppearance.mutateAsync({ + compact_shortcut_buttons: cleanShortcutButtons(shortcutDraft), + }); + toast.success("顶部快捷按钮已保存"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "保存失败"); + } + }; const themeOptions = useMemo( () => [ @@ -224,11 +442,11 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { - {isAdmin && ( - <> - - + +
@@ -257,13 +475,47 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { {globalLayoutMode === "full" ? "全量模式" : "简洁模式"}

-
- - )} - - {isAdmin && ( - <> - +
+ + )} + + {isAdmin && ( + <> + + + + setShortcutDraft((items) => [ + ...items, + { ...newShortcutButton(), sort_order: items.length }, + ]) + } + onPatch={patchShortcut} + onMove={moveShortcut} + onRemove={(id) => + setShortcutDraft((items) => + items + .filter((item) => item.id !== id) + .map((item, index) => ({ ...item, sort_order: index })), + ) + } + onAddParam={addShortcutParam} + onPatchParam={patchShortcutParam} + onRemoveParam={removeShortcutParam} + onSave={handleSaveShortcuts} + /> + + + )} + + {isAdmin && ( + <> + void }) { )} - ); -} - -function ThemePreviewCard({ - icon: Icon, - label, + ); +} + +function CompactShortcutSettings({ + buttons, + saving, + onAdd, + onPatch, + onMove, + onRemove, + onAddParam, + onPatchParam, + onRemoveParam, + onSave, +}: { + buttons: CompactShortcutButton[]; + saving: boolean; + onAdd: () => void; + onPatch: (id: string, patch: Partial) => void; + onMove: (id: string, direction: -1 | 1) => void; + onRemove: (id: string) => void; + onAddParam: (id: string) => void; + onPatchParam: ( + id: string, + index: number, + patch: Partial, + ) => void; + onRemoveParam: (id: string, index: number) => void; + onSave: () => void; +}) { + return ( +
+
+

+ 已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。 +

+
+ + +
+
+ + {buttons.length === 0 ? ( +
+ 暂无按钮,点击“添加按钮”创建一个。 +
+ ) : ( +
+ {buttons.map((button, index) => ( +
+
+
+ + onPatch(button.id, { enabled: checked }) + } + aria-label="启用快捷按钮" + /> + + {button.enabled ? "启用" : "停用"} + +
+
+
+
按钮名称
+ + onPatch(button.id, { label: event.target.value }) + } + placeholder="如:业务系统" + /> +
+
+
跳转链接
+ + onPatch(button.id, { url: event.target.value }) + } + placeholder="https://example.com/page" + className="font-mono" + /> +
+
+
+ + + +
+
+ +
+
+
+ + 路由参数 +
+ +
+ + {button.route_params.length === 0 ? ( +
+ 不追加额外参数。 +
+ ) : ( +
+ {button.route_params.map((param, paramIndex) => { + const themeMap = + param.paramType === "theme" + ? parseThemeMap(param.themeValue) + : {}; + return ( +
+
+ + onPatchParam(button.id, paramIndex, { + key: event.target.value, + }) + } + placeholder="参数名 key" + className="font-mono text-xs" + /> + + {param.paramType === "login" ? ( +
+ + {param.source === "other" && ( + + onPatchParam(button.id, paramIndex, { + customValue: event.target.value, + }) + } + placeholder="自定义值" + className="w-32 text-xs" + /> + )} +
+ ) : ( +
+ 按当前主题取值 +
+ )} + +
+ + {param.paramType === "theme" && ( +
+ {APP_THEME_COLORS.map((color) => ( + + ))} +
+ )} +
+ ); + })} +
+ )} +
+
+ ))} +
+ )} +
+ ); +} + +function ThemePreviewCard({ + icon: Icon, + label, description, active, mode, diff --git a/frontend-web/src/components/workspace/workspace-container.tsx b/frontend-web/src/components/workspace/workspace-container.tsx index d51c149f..329449b4 100644 --- a/frontend-web/src/components/workspace/workspace-container.tsx +++ b/frontend-web/src/components/workspace/workspace-container.tsx @@ -1,8 +1,8 @@ "use client"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useMemo } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { type ReactNode, useMemo } from "react"; import { Breadcrumb, @@ -30,11 +30,14 @@ export function WorkspaceContainer({ ); } -export function WorkspaceHeader({ - className, - children, - ...props -}: React.ComponentProps<"header">) { +export function WorkspaceHeader({ + className, + children, + rightActions, + ...props +}: React.ComponentProps<"header"> & { + rightActions?: ReactNode; +}) { const { t } = useI18n(); const pathname = usePathname(); const segments = useMemo(() => { @@ -51,8 +54,8 @@ export function WorkspaceHeader({ )} {...props} > -
- +
+ {segments?.[0] && ( @@ -88,11 +91,14 @@ export function WorkspaceHeader({ )} - -
- - ); -} +
+
+ {rightActions ? ( +
{rightActions}
+ ) : null} + + ); +} export function WorkspaceBody({ className, diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 73a56ac4..925728b7 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -1,268 +1,277 @@ -"use client"; - -import { - BellIcon, - BotIcon, - BrainIcon, - BriefcaseIcon, - CalendarClockIcon, - ChevronsUpDown, - DatabaseZapIcon, - GaugeIcon, - HistoryIcon, - MessageCircleQuestionIcon, - ScrollTextIcon, - Settings2Icon, - SettingsIcon, - SparklesIcon, - TagsIcon, - TrophyIcon, -} from "lucide-react"; -import { useEffect, useState } from "react"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; -import { getStoredAuth } from "@/core/auth"; -import { useI18n } from "@/core/i18n/hooks"; -import { useUnreadCount } from "@/core/notifications"; -import { - loadLogSystemSettings, - type LogSystemSettings, -} from "@/core/system-settings/api"; - -import { NotificationDialog } from "./notification-dialog"; -import { useNavigate } from "react-router-dom"; - -import { SettingsDialog } from "./settings"; - -function NavMenuButtonContent({ - isSidebarOpen, - t, -}: { - isSidebarOpen: boolean; - t: ReturnType["t"]; -}) { - return isSidebarOpen ? ( -
- - {t.workspace.settingsAndMore} - -
- ) : ( -
- -
- ); -} - -export function WorkspaceNavMenu() { - const navigate = useNavigate(); - const isAdmin = getStoredAuth()?.system_role === "admin"; - const [settingsOpen, setSettingsOpen] = useState(false); - const [settingsDefaultSection, setSettingsDefaultSection] = useState< - "appearance" | "memory" | "tools" | "about" - >("appearance"); - const [notificationOpen, setNotificationOpen] = useState(false); - const [mounted, setMounted] = useState(false); - const [logSystem, setLogSystem] = useState(null); - const { open: isSidebarOpen } = useSidebar(); - const { t } = useI18n(); - const { data: unreadCount = 0 } = useUnreadCount(); - - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - if (!isAdmin) return; - let cancelled = false; - loadLogSystemSettings() - .then((settings) => { - if (!cancelled) setLogSystem(settings); - }) - .catch(() => { - if (!cancelled) setLogSystem(null); - }); - return () => { - cancelled = true; - }; - }, [isAdmin]); - - return ( - <> - - - - - {mounted ? ( - - - - - - - - - setNotificationOpen(true)}> - - {t.notifications.title} - {unreadCount > 0 && ( - - {unreadCount > 99 ? "99+" : unreadCount} - - )} - - { - setSettingsDefaultSection("appearance"); - setSettingsOpen(true); - }} - > - - {t.common.settings} - - {isAdmin && ( - - navigate("/page/workspace/settings/llm-metrics") - } - > - - 大模型调用统计 - - )} - {isAdmin && ( - - navigate("/page/workspace/settings/checkpoint-migration") - } - > - - Checkpoint 迁移 - - )} - {isAdmin ? ( - - navigate("/page/strategy/admin/user-leaderboard") - } - > - - 用户排行榜 - - ) : ( - - navigate("/page/strategy/user-leaderboard") - } - > - - 用户排行榜 - - )} - {isAdmin && ( - - navigate("/page/workspace/admin/scheduled-tasks") - } - > - - 定时任务管理 - - )} - {isAdmin && ( - navigate("/page/workspace/admin/agents")} - > - - 智能体管理 - - )} - {isAdmin && ( - navigate("/page/workspace/admin/skills")} - > - - 技能管理 - - )} - navigate("/page/workspace/admin/tags")} - > - - {t.adminTags.title} - - {isAdmin && ( - navigate("/page/workspace/admin/positions")} - > - - 岗位管理 - - )} - {isAdmin && ( - - navigate( - "/page/workspace/settings/recommended-questions", - ) - } - > - - 推荐问题配置 - - )} - {isAdmin && logSystem?.enabled && logSystem.url && ( - - window.open( - logSystem.url, - "_blank", - "noopener,noreferrer", - ) - } - > - - {logSystem.label || "日志系统"} - - )} - - - - - ) : ( - - - - )} - - - - ); -} +"use client"; + +import { + BellIcon, + BotIcon, + BrainIcon, + BriefcaseIcon, + CalendarClockIcon, + ChevronsUpDown, + DatabaseZapIcon, + GaugeIcon, + HistoryIcon, + MessageCircleQuestionIcon, + PlugIcon, + ScrollTextIcon, + Settings2Icon, + SettingsIcon, + SparklesIcon, + TagsIcon, + TrophyIcon, +} from "lucide-react"; +import { useEffect, useState } from "react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; +import { getStoredAuth } from "@/core/auth"; +import { useI18n } from "@/core/i18n/hooks"; +import { useUnreadCount } from "@/core/notifications"; +import { + loadLogSystemSettings, + type LogSystemSettings, +} from "@/core/system-settings/api"; + +import { NotificationDialog } from "./notification-dialog"; +import { useNavigate } from "react-router-dom"; + +import { SettingsDialog } from "./settings"; + +function NavMenuButtonContent({ + isSidebarOpen, + t, +}: { + isSidebarOpen: boolean; + t: ReturnType["t"]; +}) { + return isSidebarOpen ? ( +
+ + {t.workspace.settingsAndMore} + +
+ ) : ( +
+ +
+ ); +} + +export function WorkspaceNavMenu() { + const navigate = useNavigate(); + const isAdmin = getStoredAuth()?.system_role === "admin"; + const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsDefaultSection, setSettingsDefaultSection] = useState< + "appearance" | "memory" | "tools" | "about" + >("appearance"); + const [notificationOpen, setNotificationOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const [logSystem, setLogSystem] = useState(null); + const { open: isSidebarOpen } = useSidebar(); + const { t } = useI18n(); + const { data: unreadCount = 0 } = useUnreadCount(); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!isAdmin) return; + let cancelled = false; + loadLogSystemSettings() + .then((settings) => { + if (!cancelled) setLogSystem(settings); + }) + .catch(() => { + if (!cancelled) setLogSystem(null); + }); + return () => { + cancelled = true; + }; + }, [isAdmin]); + + return ( + <> + + + + + {mounted ? ( + + + + + + + + + setNotificationOpen(true)}> + + {t.notifications.title} + {unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} + + { + setSettingsDefaultSection("appearance"); + setSettingsOpen(true); + }} + > + + {t.common.settings} + + {isAdmin && ( + + navigate("/page/workspace/settings/llm-metrics") + } + > + + 大模型调用统计 + + )} + {isAdmin && ( + + navigate("/page/workspace/settings/checkpoint-migration") + } + > + + Checkpoint 迁移 + + )} + {isAdmin ? ( + + navigate("/page/strategy/admin/user-leaderboard") + } + > + + 用户排行榜 + + ) : ( + + navigate("/page/strategy/user-leaderboard") + } + > + + 用户排行榜 + + )} + {isAdmin && ( + + navigate("/page/workspace/admin/scheduled-tasks") + } + > + + 定时任务管理 + + )} + {isAdmin && ( + navigate("/page/workspace/admin/agents")} + > + + 智能体管理 + + )} + {isAdmin && ( + navigate("/page/workspace/admin/skills")} + > + + 技能管理 + + )} + navigate("/page/workspace/admin/tags")} + > + + {t.adminTags.title} + + {isAdmin && ( + navigate("/page/workspace/admin/plugins")} + > + + 插件管理 + + )} + {isAdmin && ( + navigate("/page/workspace/admin/positions")} + > + + 岗位管理 + + )} + {isAdmin && ( + + navigate( + "/page/workspace/settings/recommended-questions", + ) + } + > + + 推荐问题配置 + + )} + {isAdmin && logSystem?.enabled && logSystem.url && ( + + window.open( + logSystem.url, + "_blank", + "noopener,noreferrer", + ) + } + > + + {logSystem.label || "日志系统"} + + )} + + + + + ) : ( + + + + )} + + + + ); +} diff --git a/frontend-web/src/core/auth/dashboard-input.ts b/frontend-web/src/core/auth/dashboard-input.ts index 389c4fe6..2168cbc2 100644 --- a/frontend-web/src/core/auth/dashboard-input.ts +++ b/frontend-web/src/core/auth/dashboard-input.ts @@ -233,6 +233,12 @@ export async function fetchDashboardInputJson( */ export type SqReportStatus = "complete" | "incomplete" | "unknown"; +export interface SqReportCheckResult { + status: SqReportStatus; + /** 提取后的 3q 研判情报报告;仅 status=complete 时有值。 */ + report?: unknown; +} + /** * rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport`` * 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。 @@ -245,20 +251,26 @@ export type SqReportStatus = "complete" | "incomplete" | "unknown"; * (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]); * - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。 */ -export async function fetchSqReportStatus(taskId: string): Promise { - if (!taskId) return "unknown"; +export async function fetchSqReportCheck(taskId: string): Promise { + if (!taskId) return { status: "unknown" }; const cfg = await fetchTaskDeeplinkConfig(); // test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。 if (cfg.test) { - return extractSqReport(cfg.mock.sqReport, taskId) ? "complete" : "incomplete"; + const report = extractSqReport(cfg.mock.sqReport, taskId); + return report ? { status: "complete", report } : { status: "incomplete" }; } // 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。 const url = sqReportUrl(); - if (!url) return "unknown"; + if (!url) return { status: "unknown" }; try { const body = await requestCrudService.get(url, { taskId }); - return extractSqReport(body, taskId) ? "complete" : "incomplete"; + const report = extractSqReport(body, taskId); + return report ? { status: "complete", report } : { status: "incomplete" }; } catch { - return "unknown"; + return { status: "unknown" }; } } + +export async function fetchSqReportStatus(taskId: string): Promise { + return (await fetchSqReportCheck(taskId)).status; +} diff --git a/frontend-web/src/core/auth/external-access-token.ts b/frontend-web/src/core/auth/external-access-token.ts new file mode 100644 index 00000000..3a3b4d8d --- /dev/null +++ b/frontend-web/src/core/auth/external-access-token.ts @@ -0,0 +1,61 @@ +import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage"; + +export const EXTERNAL_ACCESS_TOKEN_STORAGE_KEY = "access_token"; + +export function normalizeExternalAccessToken(value: unknown): string { + if (value == null) return ""; + const token = String(value).trim(); + if (!token) return ""; + const lowered = token.toLowerCase(); + return lowered === "null" || lowered === "undefined" ? "" : token; +} + +function readTokenFromStoredValue(raw: string | null): string { + const token = normalizeExternalAccessToken(raw); + if (!token) return ""; + if (!token.startsWith("{")) return token; + try { + const parsed = JSON.parse(token); + if (!parsed || typeof parsed !== "object") return token; + const obj = parsed as Record; + return ( + normalizeExternalAccessToken(obj.access_token) || + normalizeExternalAccessToken(obj.accessToken) || + normalizeExternalAccessToken(obj.token) + ); + } catch { + return token; + } +} + +export function readStoredExternalAccessTokenValue(): string { + return readTokenFromStoredValue(authStorageGet(EXTERNAL_ACCESS_TOKEN_STORAGE_KEY)); +} + +export function readExternalAccessTokenValue(userInfo: Record): string { + return normalizeExternalAccessToken(userInfo.access_token) || normalizeExternalAccessToken(userInfo.accessToken); +} + +export function readCurrentExternalAccessTokenValue(userInfo: Record): string { + return readStoredExternalAccessTokenValue() || readExternalAccessTokenValue(userInfo); +} + +export function writeExternalAccessTokenFields( + userInfo: Record, + token: string, +): void { + userInfo.access_token = token; + userInfo.accessToken = token; +} + +export function writeStoredExternalAccessToken(token: string): void { + const normalized = normalizeExternalAccessToken(token); + if (normalized) { + authStorageSet(EXTERNAL_ACCESS_TOKEN_STORAGE_KEY, normalized); + } +} + +export function deleteExternalAccessTokenFields(userInfo: Record): void { + delete userInfo.access_token; + delete userInfo.accessToken; +} diff --git a/frontend-web/src/core/auth/scoped-storage.ts b/frontend-web/src/core/auth/scoped-storage.ts index eb66ea70..09cfeba6 100644 --- a/frontend-web/src/core/auth/scoped-storage.ts +++ b/frontend-web/src/core/auth/scoped-storage.ts @@ -62,7 +62,7 @@ export function authStorageRemove(key: string): void { * ticket/userId/env/maxkeyOnlineToken/userDetailToken)。用于「开启独立会话」时把当前登录态从 * localStorage 快照搬进 sessionStorage,避免切换存储后被登出。 */ -const PROTECTED_AUTH_EXACT_KEYS = ["deerflow.auth", "userInfo", "userId", "tools_token"]; +const PROTECTED_AUTH_EXACT_KEYS = ["deerflow.auth", "userInfo", "userId", "tools_token", "access_token"]; const PROTECTED_AUTH_PREFIX = "login."; function isProtectedAuthKey(key: string): boolean { diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index 673073d5..6b51a73a 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -234,7 +234,7 @@ export interface TaskDeeplinkTarget { } /** xdfx(行动分析)开场信息里要追加的行动信息:行动标题 + 行动内容 + 行动id。 */ -interface ActionOpeningInfo { +export interface ActionOpeningInfo { /** 行动id(action_id)。 */ actionId?: string; /** 行动标题(行动名称 actionName)。 */ @@ -264,8 +264,12 @@ function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: Action * 3qfx/rwfx 这类“按任务id 直接问答”的任务工作区,在 ``TaskContext`` 丢失但 * ``login.copTaskDetail`` 仍存在时,可用它重新拼出开场词做前端自恢复。 */ -export function buildTaskOpeningText(detail: CopTaskDetail, taskId: string): string { - return buildOpeningText(detail, taskId); +export function buildTaskOpeningText( + detail: CopTaskDetail, + taskId: string, + action?: ActionOpeningInfo, +): string { + return buildOpeningText(detail, taskId, action); } /** diff --git a/frontend-web/src/core/auth/yuser-login.ts b/frontend-web/src/core/auth/yuser-login.ts index 21936506..a063149c 100644 --- a/frontend-web/src/core/auth/yuser-login.ts +++ b/frontend-web/src/core/auth/yuser-login.ts @@ -1,5 +1,13 @@ import { requestCrudService } from "@/service/request-axios-service"; import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage"; +import { + deleteExternalAccessTokenFields, + normalizeExternalAccessToken, + readStoredExternalAccessTokenValue, + readExternalAccessTokenValue, + writeExternalAccessTokenFields, + writeStoredExternalAccessToken, +} from "@/core/auth/external-access-token"; const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || ""; @@ -8,6 +16,8 @@ export const EXTERNAL_USER_ID_STORAGE_KEY = "userId"; export const EXTERNAL_USER_INFO_STORAGE_KEY = "userInfo"; export const GENERAL_QA_ROUTE = "/page/workspace/chats/new"; +const YUSER_INFO_CACHE_KEY = "deerflow.yuserInfoByYUserId"; + type UserDetailResponse = { data?: { id?: string; @@ -15,6 +25,147 @@ type UserDetailResponse = { }; }; +function readExternalUserInfo(): Record { + try { + const raw = authStorageGet(EXTERNAL_USER_INFO_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function readStoredYUserId(info: Record): string { + const value = info.yUserId; + return value == null ? "" : String(value).trim(); +} + +function normalizeExternalUserId(value: unknown): string { + if (value == null) return ""; + const text = String(value).trim(); + if (!text) return ""; + const lowered = text.toLowerCase(); + return lowered === "null" || lowered === "undefined" ? "" : text; +} + +function maskToken(token: string): string { + if (!token) return ""; + if (token.length <= 10) return `${token.slice(0, 2)}***(${token.length})`; + return `${token.slice(0, 6)}...${token.slice(-4)}(${token.length})`; +} + +function summarizeUserInfo(info: Record): Record { + return { + keys: Object.keys(info), + yUserId: info.yUserId, + userId: info.userId, + userName: info.userName, + gateDepartName: info.gateDepartName, + typeName: info.typeName, + access_token: maskToken(normalizeExternalAccessToken(info.access_token)), + accessToken: maskToken(normalizeExternalAccessToken(info.accessToken)), + }; +} + +function logYUserLogin(step: string, payload: Record): void { + if (typeof console === "undefined") return; + console.info(`[yUserId-login] ${step}`, payload); +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isUsableExternalValue(value: unknown): boolean { + if (value == null) return false; + if (typeof value !== "string") return true; + const trimmed = value.trim(); + if (!trimmed) return false; + const lowered = trimmed.toLowerCase(); + return lowered !== "null" && lowered !== "undefined"; +} + +function readYUserInfoCache(): Record> { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(YUSER_INFO_CACHE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (!isPlainObject(parsed)) return {}; + const cache: Record> = {}; + for (const [key, value] of Object.entries(parsed)) { + if (isPlainObject(value)) cache[key] = value; + } + return cache; + } catch { + return {}; + } +} + +function readCachedYUserInfo(yUserId: string): Record { + return readYUserInfoCache()[yUserId] ?? {}; +} + +function writeCachedYUserInfo(yUserId: string, info: Record): void { + if (typeof window === "undefined") return; + try { + const cache = readYUserInfoCache(); + cache[yUserId] = info; + window.localStorage.setItem(YUSER_INFO_CACHE_KEY, JSON.stringify(cache)); + } catch { + /* noop */ + } +} + +function buildExternalUserInfo( + yUserId: string, + result: Record, + access_token: string | null | undefined, + previous: Record, + cached: Record, +): Record { + const next: Record = { ...cached }; + for (const [key, value] of Object.entries(result)) { + if (isUsableExternalValue(value)) { + next[key] = value; + } + } + next.yUserId = yUserId; + const externalUserId = + normalizeExternalUserId(result.id) || + normalizeExternalUserId(result.userId) || + normalizeExternalUserId(result.user_id) || + yUserId; + next.userId = externalUserId; + + const incomingAccessToken = readExternalAccessTokenValue(result); + if (incomingAccessToken) { + writeExternalAccessTokenFields(next, incomingAccessToken); + return next; + } + + const queryAccessToken = normalizeExternalAccessToken(access_token); + if (queryAccessToken) { + writeExternalAccessTokenFields(next, queryAccessToken); + return next; + } + + const previousYUserId = readStoredYUserId(previous); + const previousAccessToken = + previousYUserId === yUserId ? readExternalAccessTokenValue(previous) : ""; + const cachedAccessToken = readExternalAccessTokenValue(cached); + const fallbackAccessToken = previousAccessToken || cachedAccessToken; + if (fallbackAccessToken) { + writeExternalAccessTokenFields(next, fallbackAccessToken); + } else { + deleteExternalAccessTokenFields(next); + } + return next; +} + /** * External login via yUserId + optional token (same flow as web-code Login.tsx): * persist tools_token, resolve user via consumer API, store userId/userInfo. @@ -22,26 +173,61 @@ type UserDetailResponse = { export async function loginByYUserId( yUserId: string, token?: string | null, + access_token?: string | null, ): Promise { if (token) { authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token); } + const requestUrl = `${CONSUMER_URL}/cop/userDetailByYuserId`; + logYUserLogin("request", { + requestUrl, + yUserId, + hasToolsToken: Boolean(token), + queryAccessToken: maskToken(normalizeExternalAccessToken(access_token)), + }); + const res = (await requestCrudService.get( - `${CONSUMER_URL}/cop/userDetailByYuserId`, + requestUrl, { yuserId: yUserId }, )) as UserDetailResponse; // consumer 返回 { accessToken, gateDepartName, typeName, userName }。 // 一并存入路由参数 yUserId,使「应用管理」里 source=yUserId 的路由参数能取到(与 // source=userName/gateDepartName/typeName 直接取接口值统一走 userInfo[source])。 - // 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的 - // access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。 + // 注意:接口返回的 accessToken 优先;登录 URL 的 access_token 只在接口值为空时兜底。 + // The consumer API may return accessToken. URL access_token is only used as + // a fallback when that API token is empty. const result = res.data ?? {}; - authStorageSet( - EXTERNAL_USER_INFO_STORAGE_KEY, - JSON.stringify({ ...result, yUserId }), - ); + logYUserLogin("response", { + keys: Object.keys(result), + id: result.id, + userId: result.userId, + user_id: result.user_id, + userName: result.userName, + gateDepartName: result.gateDepartName, + typeName: result.typeName, + access_token: maskToken(normalizeExternalAccessToken(result.access_token)), + accessToken: maskToken(normalizeExternalAccessToken(result.accessToken)), + }); + const previous = readExternalUserInfo(); + const cached = readCachedYUserInfo(yUserId); + const next = buildExternalUserInfo(yUserId, result, access_token, previous, cached); + const externalUserId = normalizeExternalUserId(next.userId); + if (externalUserId) { + authStorageSet(EXTERNAL_USER_ID_STORAGE_KEY, externalUserId); + } + const externalAccessToken = readExternalAccessTokenValue(next); + if (externalAccessToken) { + writeStoredExternalAccessToken(externalAccessToken); + } + authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(next)); + writeCachedYUserInfo(yUserId, next); + logYUserLogin("stored", { + userId: externalUserId, + topLevelAccessToken: maskToken(readStoredExternalAccessTokenValue()), + userInfo: summarizeUserInfo(next), + }); } export function persistToolsToken(token: string): void { @@ -49,27 +235,21 @@ export function persistToolsToken(token: string): void { } /** - * 把内网登录路由参数 access_token 合并写入 userInfo.accessToken(覆盖接口返回的同名值)。 + * 把内网登录路由参数 access_token 合并写入 userInfo.access_token / userInfo.accessToken。 * 据此拼参的有:XK/PG 中任务 iframe(XK 用 accessToken、PG 用 access_token,二者均读 - * external-links.getAccessToken() = userInfo.accessToken);以及「应用管理」里 - * source=accessToken 的轻应用路由参数(resolveRouteParamValue 取 userInfo.accessToken)。 + * external-links.getAccessToken());以及「应用管理」里 source=access_token / accessToken + * 的轻应用路由参数(resolveRouteParamValue 统一读两种字段)。 * 用合并而非覆盖整对象,避免抹掉 loginByYUserId 从 consumer 拿到的用户信息。 */ export function persistAccessToken(token: string): void { - const trimmed = token?.trim(); + const trimmed = normalizeExternalAccessToken(token); if (!trimmed) return; - let info: Record = {}; - try { - const raw = authStorageGet(EXTERNAL_USER_INFO_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object") { - info = parsed as Record; - } - } - } catch { - info = {}; - } - info.accessToken = trimmed; + const info = readExternalUserInfo(); + writeExternalAccessTokenFields(info, trimmed); + writeStoredExternalAccessToken(trimmed); authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info)); + const yUserId = readStoredYUserId(info); + if (yUserId) { + writeCachedYUserInfo(yUserId, info); + } } diff --git a/frontend-web/src/core/clipboard.ts b/frontend-web/src/core/clipboard.ts new file mode 100644 index 00000000..255d7844 --- /dev/null +++ b/frontend-web/src/core/clipboard.ts @@ -0,0 +1,44 @@ +export async function copyTextToClipboard(text: string): Promise { + let clipboardError: unknown; + + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch (error) { + clipboardError = error; + } + } + + if (typeof document === "undefined" || !document.body) { + throw clipboardError instanceof Error + ? clipboardError + : new Error("Clipboard is not available"); + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.width = "1px"; + textarea.style.height = "1px"; + textarea.style.opacity = "0"; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + try { + const copied = document.execCommand("copy"); + if (!copied) { + throw clipboardError instanceof Error + ? clipboardError + : new Error("Copy command failed"); + } + } finally { + textarea.remove(); + } +} diff --git a/frontend-web/src/core/plugin-management/domain-agent.test.mjs b/frontend-web/src/core/plugin-management/domain-agent.test.mjs new file mode 100644 index 00000000..06ba2bf9 --- /dev/null +++ b/frontend-web/src/core/plugin-management/domain-agent.test.mjs @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + normalizeDomainInput, + resolveIncomingIframeSelection, + resolveConfiguredIframeAgentId, + resolveDomainMappedAgentId, +} from "./domain-agent.ts"; + +test("normalizes full urls and bare hosts to comparable domains", () => { + assert.equal( + normalizeDomainInput(" https://Foo.Example.com/path?q=1 "), + "foo.example.com", + ); + assert.equal( + normalizeDomainInput("foo.example.com:8080/landing"), + "foo.example.com:8080", + ); +}); + +test("resolves the configured agent for a matching pageContext url", () => { + assert.equal( + resolveDomainMappedAgentId({ + pageUrl: "https://foo.example.com/article/123", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1", "agent-2"], + }), + "agent-1", + ); +}); + +test("ignores disabled mappings and unavailable agents", () => { + assert.equal( + resolveDomainMappedAgentId({ + pageUrl: "https://foo.example.com/article/123", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: false, + }, + { + id: "m2", + domain: "bar.example.com", + agentId: "agent-2", + enabled: true, + }, + ], + availableAgentIds: ["agent-3"], + }), + undefined, + ); +}); + +test("does not auto-select after manual selection or once a conversation has started", () => { + assert.equal( + resolveConfiguredIframeAgentId({ + pageUrl: "https://foo.example.com", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1"], + isNewThread: true, + hasStartedConversation: false, + manualSelectionLocked: true, + }), + undefined, + ); + + assert.equal( + resolveConfiguredIframeAgentId({ + pageUrl: "https://foo.example.com", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1"], + isNewThread: true, + hasStartedConversation: true, + manualSelectionLocked: false, + }), + undefined, + ); +}); + +test("falls back to default assistant when url is unmapped and user has not manually selected an agent", () => { + assert.deepEqual( + resolveIncomingIframeSelection({ + pageUrl: "https://unknown.example.com", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1", "agent-2"], + manualSelectedAgentId: null, + }), + { + selectedAgentId: null, + forceDefaultAssistant: true, + }, + ); +}); + +test("falls back to the user's manual selection when url is unmapped", () => { + assert.deepEqual( + resolveIncomingIframeSelection({ + pageUrl: "https://unknown.example.com", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1", "agent-2"], + manualSelectedAgentId: "agent-2", + }), + { + selectedAgentId: "agent-2", + forceDefaultAssistant: false, + }, + ); +}); + +test("mapped domain overrides the user's manual selection for that incoming message", () => { + assert.deepEqual( + resolveIncomingIframeSelection({ + pageUrl: "https://foo.example.com/article", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1", "agent-2"], + manualSelectedAgentId: "agent-2", + }), + { + selectedAgentId: "agent-1", + forceDefaultAssistant: false, + }, + ); +}); + +test("unmapped incoming url resets auto-selected agent back to default when there is no manual selection", () => { + assert.deepEqual( + resolveIncomingIframeSelection({ + pageUrl: "https://another-unknown.example.com/article", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1", "agent-2"], + manualSelectedAgentId: null, + }), + { + selectedAgentId: null, + forceDefaultAssistant: true, + }, + ); +}); diff --git a/frontend-web/src/core/plugin-management/domain-agent.ts b/frontend-web/src/core/plugin-management/domain-agent.ts new file mode 100644 index 00000000..59da44fc --- /dev/null +++ b/frontend-web/src/core/plugin-management/domain-agent.ts @@ -0,0 +1,135 @@ +export type DomainAgentMapping = { + id: string; + domain: string; + agentId: string; + enabled: boolean; +}; + +function trimToUndefined(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeAgentId(agentId: string | null | undefined): string | undefined { + const trimmed = agentId?.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizeDomainInput( + value: string | null | undefined, +): string | undefined { + const trimmed = trimToUndefined(value); + if (!trimmed) return undefined; + + const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed) + ? trimmed + : `https://${trimmed}`; + + try { + const parsed = new URL(candidate); + const host = parsed.host.trim().toLowerCase(); + return host || undefined; + } catch { + return undefined; + } +} + +export function resolveDomainMappedAgentId({ + pageUrl, + mappings, + availableAgentIds, +}: { + pageUrl: string | null | undefined; + mappings: DomainAgentMapping[]; + availableAgentIds?: readonly string[] | null; +}): string | undefined { + const host = normalizeDomainInput(pageUrl); + if (!host) return undefined; + + const allowedAgents = + availableAgentIds && availableAgentIds.length > 0 + ? new Set(availableAgentIds) + : null; + + for (const mapping of mappings) { + if (!mapping.enabled) continue; + if (normalizeDomainInput(mapping.domain) !== host) continue; + if (allowedAgents && !allowedAgents.has(mapping.agentId)) continue; + return mapping.agentId; + } + + return undefined; +} + +export function resolveConfiguredIframeAgentId({ + pageUrl, + mappings, + availableAgentIds, + isNewThread, + hasStartedConversation, + manualSelectionLocked, +}: { + pageUrl: string | null | undefined; + mappings: DomainAgentMapping[]; + availableAgentIds?: readonly string[] | null; + isNewThread: boolean; + hasStartedConversation: boolean; + manualSelectionLocked: boolean; +}): string | undefined { + if (!isNewThread || hasStartedConversation || manualSelectionLocked) { + return undefined; + } + + return resolveDomainMappedAgentId({ + pageUrl, + mappings, + availableAgentIds, + }); +} + +export function resolveIncomingIframeSelection({ + pageUrl, + mappings, + availableAgentIds, + manualSelectedAgentId, +}: { + pageUrl: string | null | undefined; + mappings: DomainAgentMapping[]; + availableAgentIds?: readonly string[] | null; + manualSelectedAgentId: string | null; +}): { + selectedAgentId: string | null; + forceDefaultAssistant: boolean; +} { + const mappedAgentId = resolveDomainMappedAgentId({ + pageUrl, + mappings, + availableAgentIds, + }); + if (mappedAgentId) { + return { + selectedAgentId: mappedAgentId, + forceDefaultAssistant: false, + }; + } + + const allowedAgents = + availableAgentIds && availableAgentIds.length > 0 + ? new Set(availableAgentIds) + : null; + const normalizedManualAgentId = normalizeAgentId(manualSelectedAgentId); + if ( + normalizedManualAgentId && + (!allowedAgents || allowedAgents.has(normalizedManualAgentId)) + ) { + return { + selectedAgentId: normalizedManualAgentId, + forceDefaultAssistant: false, + }; + } + + return { + selectedAgentId: null, + forceDefaultAssistant: true, + }; +} diff --git a/frontend-web/src/core/skill-display/inline-citations.test.ts b/frontend-web/src/core/skill-display/inline-citations.test.ts index 1a09a798..db6bf1d2 100644 --- a/frontend-web/src/core/skill-display/inline-citations.test.ts +++ b/frontend-web/src/core/skill-display/inline-citations.test.ts @@ -8,7 +8,7 @@ function makeSources(count: number): RuntimeDisplaySource[] { index: position + 1, skillName: "knowledge-base-search", mode: "citation", - title: `参考文献 ${position + 1}`, + title: `Reference ${position + 1}`, type: "result", snippet: "", raw: {}, @@ -19,7 +19,7 @@ function citation(index: number): string { return `[citation:${index}](#reference-${index})`; } -test("全角单编号 【1】【2】 解析为引用徽章", () => { +test("converts full-width numbered references", () => { const out = applyInlineCitations("结论见【1】【2】。", makeSources(2)); assert.equal(out.includes(citation(1)), true); assert.equal(out.includes(citation(2)), true); @@ -27,21 +27,21 @@ test("全角单编号 【1】【2】 解析为引用徽章", () => { assert.equal(out.includes("【2】"), false); }); -test("全角数字 【1】 也能识别", () => { +test("converts full-width digits", () => { const out = applyInlineCitations("参考【1】和【2】。", makeSources(2)); assert.equal(out.includes(citation(1)), true); assert.equal(out.includes(citation(2)), true); }); -test("全角括号合并编号 【1,2】/【1,2】/【1、2】 拆成多个引用", () => { - for (const marker of ["【1,2】", "【1,2】", "【1、2】"]) { +test("splits merged reference markers", () => { + for (const marker of ["【1,2】", "【1、2】", "【1,2】"]) { const out = applyInlineCitations(`见${marker}。`, makeSources(2)); assert.equal(out.includes(citation(1)), true, marker); assert.equal(out.includes(citation(2)), true, marker); } }); -test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => { +test("expands reference ranges", () => { const dash = applyInlineCitations("见【1-3】。", makeSources(3)); assert.equal(dash.includes(citation(1)), true); assert.equal(dash.includes(citation(2)), true); @@ -53,21 +53,26 @@ test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => { assert.equal(tilde.includes(citation(4)), true); }); -test("超出来源范围的全角编号保持原样,不误判正文", () => { - // 只有 1 条来源,【3】 没有对应来源 → 不应转换 +test("does not rewrite out-of-range references", () => { const out = applyInlineCitations("正文【3】不是引用。", makeSources(1)); assert.equal(out.includes("【3】"), true); assert.equal(out.includes("#reference-3"), false); }); -test("非数字的全角括号不被改写", () => { +test("does not rewrite non-numeric bracket text", () => { const out = applyInlineCitations("见【附录】小节。", makeSources(2)); assert.equal(out.includes("【附录】"), true); }); -test("半角写法仍然正常工作", () => { +test("keeps half-width syntax working", () => { const out = applyInlineCitations("见[1][2]和[1,3]。", makeSources(3)); assert.equal(out.includes(citation(1)), true); assert.equal(out.includes(citation(2)), true); assert.equal(out.includes(citation(3)), true); }); + +test("accepts spaced and full-width square brackets", () => { + const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2)); + assert.equal(out.includes(citation(1)), true); + assert.equal(out.includes(citation(2)), true); +}); diff --git a/frontend-web/src/core/skill-display/runtime-parsing.test.ts b/frontend-web/src/core/skill-display/runtime-parsing.test.ts new file mode 100644 index 00000000..35191d4c --- /dev/null +++ b/frontend-web/src/core/skill-display/runtime-parsing.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { Message } from "@langchain/langgraph-sdk"; + +import type { Skill } from "@/core/skills/type"; + +import { buildSkillDisplayMap, collectRuntimeDisplaySources } from "./runtime"; + +function makeSkill(): Skill { + return { + name: "custom-search", + description: "test", + category: "test", + license: "internal", + enabled: true, + display: { + enabled: true, + mode: "citation", + resultPath: "docs", + titleField: "title", + summaryField: "summary", + contentField: "content", + sourceField: "source", + authorField: null, + typeField: null, + timeField: null, + scoreField: null, + urlField: "url", + idField: "id", + linkStrategy: "auto", + linkTemplate: null, + citationToken: "doubleBracket", + maxItems: 20, + customInstruction: "", + tableColumns: [], + resultSets: [], + }, + }; +} + +function makeMessages(toolContent: Message["content"]): Message[] { + return [ + { + id: "ai-1", + type: "ai", + content: "", + tool_calls: [{ id: "call-1", name: "custom-search", args: {} }], + } as unknown as Message, + { + id: "tool-1", + type: "tool", + name: "custom-search", + tool_call_id: "call-1", + content: toolContent, + } as unknown as Message, + ]; +} + +test("collects citations from fenced nested json", () => { + const messages = makeMessages( + "```json\n{\"payload\":{\"docs\":[{\"id\":\"doc-1\",\"title\":\"Alpha\",\"summary\":\"Brief\",\"content\":\"Body\",\"source\":\"KB\",\"url\":\"https://example.com/a\"}]}}\n```", + ); + const sources = collectRuntimeDisplaySources( + messages, + buildSkillDisplayMap([makeSkill()]), + ); + assert.equal(sources.length, 1); + assert.equal(sources[0]?.title, "Alpha"); + assert.equal(sources[0]?.url, "https://example.com/a"); +}); + +test("collects citations from structured tool payload arrays", () => { + const messages = makeMessages([ + { + type: "json", + json: { + wrapper: { + docs: [ + { + id: "doc-2", + title: "Beta", + summary: "Second", + content: "Payload", + source: "Skill", + url: "https://example.com/b", + }, + ], + }, + }, + }, + ] as unknown as Message["content"]); + const sources = collectRuntimeDisplaySources( + messages, + buildSkillDisplayMap([makeSkill()]), + ); + assert.equal(sources.length, 1); + assert.equal(sources[0]?.title, "Beta"); + assert.equal(sources[0]?.source, "Skill"); +}); diff --git a/frontend-web/src/core/skill-display/runtime.ts b/frontend-web/src/core/skill-display/runtime.ts index 877b397b..970c7433 100644 --- a/frontend-web/src/core/skill-display/runtime.ts +++ b/frontend-web/src/core/skill-display/runtime.ts @@ -588,7 +588,7 @@ function replaceBracketCitationTokens( byIndex: Map, orderedSources: RuntimeDisplaySource[], ): string { - const tokenPattern = /\\?\[(\d{1,3})\\?\](?!\()/g; + const tokenPattern = /\\?\[\s*([0-9\uFF10-\uFF19]{1,3})\s*\\?\](?!\()/g; let cursor = 0; let next = ""; for (const match of content.matchAll(tokenPattern)) { @@ -598,13 +598,24 @@ function replaceBracketCitationTokens( continue; } next += content.slice(cursor, start); - next += citationMarkdown(match[0], Number(match[1]), byIndex, orderedSources); + next += citationMarkdown( + match[0], + Number(normalizeAsciiDigits(match[1] ?? "")), + byIndex, + orderedSources, + ); cursor = start + match[0].length; } next += content.slice(cursor); return next; } +function normalizeAsciiDigits(value: string): string { + return value.replace(/[\uFF10-\uFF19]/g, (digit) => + String.fromCharCode(digit.charCodeAt(0) - 0xfee0), + ); +} + function normalizeLooseCitationTokens( content: string, byIndex: Map, @@ -637,13 +648,17 @@ function normalizeLooseCitationTokens( // 避免把正文里的【…】误判成引用。转成半角后交给下面的半角逻辑统一处理。 // 全角单编号:【1】/【1】 - let next = content.replace( + let next = content + .replace(/[/g, "[") + .replace(/]/g, "]") + .replace(/[,、]/g, ",") + .replace( /【\s*([0-90-9]{1,3})\s*】/g, (match, rawIndex: string) => { - const index = Number(toAsciiDigits(rawIndex)); + const index = Number(normalizeAsciiDigits(rawIndex)); return isValidIndex(index) ? `[${index}]` : match; }, - ); + ); // 全角合并编号:【1,2】/【1,2】/【1、2】 next = next.replace( @@ -651,7 +666,7 @@ function normalizeLooseCitationTokens( (match, rawList: string) => { const indexes = rawList .split(/[,,、]/) - .map((part) => Number(toAsciiDigits(part.trim()))); + .map((part) => Number(normalizeAsciiDigits(part.trim()))); if (indexes.some((index) => !isValidIndex(index))) { return match; } @@ -663,8 +678,8 @@ function normalizeLooseCitationTokens( next = next.replace( /【\s*([0-90-9]{1,3})\s*[-–~~]\s*([0-90-9]{1,3})\s*】/g, (match, rawStart: string, rawEnd: string) => { - const start = Number(toAsciiDigits(rawStart)); - const end = Number(toAsciiDigits(rawEnd)); + const start = Number(normalizeAsciiDigits(rawStart)); + const end = Number(normalizeAsciiDigits(rawEnd)); if ( end <= start || end - start > 20 || @@ -978,20 +993,23 @@ function extractStructuredToolPayload(content: unknown): unknown { } function unwrapToolPayloadObject(value: Record): unknown { - if ( - Array.isArray(value.results) || - Array.isArray(value.data) || - Array.isArray(value.items) - ) { - return value; + const directCandidate = findDisplayPayloadCandidate(value, 0); + if (directCandidate !== undefined) { + return directCandidate; } - for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) { - const nested = value[key]; + for (const nested of Object.values(value)) { if (typeof nested === "string") { const parsed = parseJSONLikeText(nested); if (parsed !== undefined) { + if (isRecord(parsed)) { + const structured = findDisplayPayloadCandidate(parsed, 0); + if (structured !== undefined) { + return structured; + } + } return parsed; } + continue; } if (isRecord(nested)) { const unwrapped = unwrapToolPayloadObject(nested); @@ -1008,6 +1026,14 @@ function parseJSONLikeText(text: string): unknown { if (!trimmed) { return undefined; } + const fenced = extractFencedJSON(trimmed); + if (fenced) { + try { + return JSON.parse(fenced) as unknown; + } catch { + // Fall through to best-effort parsing. + } + } try { return JSON.parse(trimmed) as unknown; } catch { @@ -1016,17 +1042,9 @@ function parseJSONLikeText(text: string): unknown { } function extractJSONFromText(text: string): unknown { - const candidates: Array<[number, number]> = []; - const firstObj = text.indexOf("{"); - if (firstObj !== -1) candidates.push([firstObj, text.lastIndexOf("}")]); - const firstArr = text.indexOf("["); - if (firstArr !== -1) candidates.push([firstArr, text.lastIndexOf("]")]); - candidates.sort(([a], [b]) => a - b); - for (const [start, end] of candidates) { - if (start < 0 || end <= start) continue; - const slice = text.slice(start, end + 1); + for (const candidate of iterateBalancedJsonCandidates(text)) { try { - return JSON.parse(slice); + return JSON.parse(candidate); } catch { // try the next candidate } @@ -1034,6 +1052,108 @@ function extractJSONFromText(text: string): unknown { return undefined; } +function *iterateBalancedJsonCandidates(text: string): Generator { + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (char !== "{" && char !== "[") { + continue; + } + const candidate = extractBalancedJson(text, index); + if (candidate) { + yield candidate; + } + } +} + +function extractBalancedJson(text: string, start: number): string | null { + const opening = text[start]; + if (opening !== "{" && opening !== "[") { + return null; + } + const closing = opening === "{" ? "}" : "]"; + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < text.length; index++) { + const char = text[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "\"") { + inString = false; + } + continue; + } + if (char === "\"") { + inString = true; + continue; + } + if (char === opening) { + depth += 1; + continue; + } + if (char === closing) { + depth -= 1; + if (depth === 0) { + return text.slice(start, index + 1); + } + } + } + return null; +} + +function extractFencedJSON(text: string): string | null { + const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(text); + return match?.[1]?.trim() ?? null; +} + +function findDisplayPayloadCandidate(value: unknown, depth: number): unknown { + if (depth > 8) { + return undefined; + } + if (Array.isArray(value)) { + const objectItems = value.filter( + (item): item is Record => + isRecord(item), + ); + if (value.length === 0 || objectItems.length === value.length) { + return { results: objectItems }; + } + for (const item of value) { + const nested = findDisplayPayloadCandidate(item, depth + 1); + if (nested !== undefined) { + return nested; + } + } + return undefined; + } + if (!isRecord(value)) { + return undefined; + } + for (const key of ["results", "data", "items", "docs", "records", "rows", "list", "hits", "sources"]) { + const nested = value[key]; + if (!Array.isArray(nested)) { + continue; + } + const objectItems = nested.filter( + (item): item is Record => + isRecord(item), + ); + if (nested.length === 0 || objectItems.length === nested.length) { + return { ...value, [key]: objectItems }; + } + } + for (const nested of Object.values(value)) { + const candidate = findDisplayPayloadCandidate(nested, depth + 1); + if (candidate !== undefined) { + return candidate; + } + } + return undefined; +} + function extractSearchPayloadFromText(text: string): unknown { if (!text.includes('"results"') || !text.includes('"recUuid"')) { return undefined; diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index e68c2f89..0117455e 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -141,17 +141,39 @@ export async function getSkillDisplaySample( return response.json() as Promise; } -export interface InstallSkillRequest { - thread_id: string; - path: string; -} - -export interface InstallSkillResponse { - success: boolean; - skill_name: string; - message: string; - overwritten?: boolean; -} +export interface InstallSkillRequest { + thread_id: string; + path: string; + skill_name?: string; +} + +export interface InstallSkillResponse { + success: boolean; + skill_name: string; + message: string; + overwritten?: boolean; +} + +export interface PreviewSkillInstallRequest { + thread_id: string; + path: string; +} + +export interface PreviewSkillInstallResponse { + ok: boolean; + skill_name: string; + description: string; + license: string | null; + allowed_tools: string[]; + can_install_as_is: boolean; + owned_by_me: boolean; + owned_by_other: boolean; + builtin_conflict: boolean; + owner_username?: string | null; + owner_user_id?: string | null; + message: string; + warnings: string[]; +} export interface ValidateSkillUploadResponse { ok: boolean; @@ -164,9 +186,9 @@ export interface ValidateSkillUploadResponse { warnings: string[]; } -export async function installSkill( - request: InstallSkillRequest, -): Promise { +export async function installSkill( + request: InstallSkillRequest, +): Promise { const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install`, { method: "POST", headers: { @@ -187,8 +209,24 @@ export async function installSkill( }; } - return response.json(); -} + return response.json(); +} + +export async function previewSkillInstall( + request: PreviewSkillInstallRequest, +): Promise { + const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install-preview`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + if (!response.ok) { + throw await parseDetailedError(response, "Failed to preview skill install"); + } + return response.json() as Promise; +} async function parseDetailedError(response: Response, fallback: string): Promise { const data = await response.json().catch(() => ({})); diff --git a/frontend-web/src/core/strategy/external-links.ts b/frontend-web/src/core/strategy/external-links.ts index 7d890d0d..f1ad3698 100644 --- a/frontend-web/src/core/strategy/external-links.ts +++ b/frontend-web/src/core/strategy/external-links.ts @@ -1,6 +1,7 @@ /** Strategy 模块外部 iframe / 嵌入页链接(来自 Vite 环境变量) */ import { authStorageGet } from "@/core/auth/scoped-storage"; +import { readCurrentExternalAccessTokenValue } from "@/core/auth/external-access-token"; function trimTrailingSlash(url: string): string { return url.replace(/\/+$/, ""); @@ -43,8 +44,7 @@ function readUserInfo(): Record { /** 当前登录态 accessToken(无则空串)。 */ export function getAccessToken(): string { - const value = readUserInfo().accessToken; - return value == null ? "" : String(value); + return readCurrentExternalAccessTokenValue(readUserInfo()); } /** diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 6ca628c2..d2c0564e 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -8,12 +8,115 @@ export interface PromptPrefixSettings { prompt_prefix: string; } +export interface PluginDomainAgentMappingDto { + id: string; + domain: string; + agent_id: string; + enabled: boolean; +} + +export interface PluginManagementSettings { + domainAgentMappings: Array<{ + id: string; + domain: string; + agentId: string; + enabled: boolean; + }>; +} + +export interface PluginManagementUpdate { + domainAgentMappings?: Array<{ + id: string; + domain: string; + agentId: string; + enabled: boolean; + }>; +} + export interface PromptPrefixUpdate { enabled?: boolean; switch_label?: string; prompt_prefix?: string; } +function fromPluginManagementDto(data: { + domain_agent_mappings: PluginDomainAgentMappingDto[]; +}): PluginManagementSettings { + return { + domainAgentMappings: (data.domain_agent_mappings ?? []).map((item) => ({ + id: item.id, + domain: item.domain, + agentId: item.agent_id, + enabled: item.enabled, + })), + }; +} + +function toPluginManagementDto(body: PluginManagementUpdate) { + return { + domain_agent_mappings: (body.domainAgentMappings ?? []).map((item) => ({ + id: item.id, + domain: item.domain, + agent_id: item.agentId, + enabled: item.enabled, + })), + }; +} + +export async function loadPluginManagementSettings(): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/plugin-management`, + ); + if (!response.ok) { + throw new Error( + `Failed to load plugin management settings: ${response.status}`, + ); + } + const data = (await response.json()) as { + domain_agent_mappings: PluginDomainAgentMappingDto[]; + }; + const mapped = fromPluginManagementDto(data); + writeYLog({ + optType: OptType.view, + optDetail: "CM助手-全量-系统设置-插件管理查看", + url: "/api/system-settings/plugin-management", + returnParam: mapped, + }); + return mapped; +} + +export async function updatePluginManagementSettings( + body: PluginManagementUpdate, +): Promise { + const payload = toPluginManagementDto(body); + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/plugin-management`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!response.ok) { + const detail = await response + .json() + .catch(() => ({ detail: "Failed to update plugin management settings" })); + throw new Error(detail.detail ?? "Failed to update plugin management settings"); + } + const data = (await response.json()) as { + domain_agent_mappings: PluginDomainAgentMappingDto[]; + }; + const mapped = fromPluginManagementDto(data); + writeYLog({ + optType: OptType.edit, + optDetail: "CM助手-全量-系统设置-插件管理编辑", + url: "/api/system-settings/plugin-management", + reqParam: payload, + returnParam: mapped, + }); + return mapped; +} + export async function loadPromptPrefixSettings(): Promise { const response = await apiFetch( `${getBackendBaseURL()}/api/system-settings/prompt-prefix`, @@ -176,12 +279,31 @@ export async function updatePublishSquares( export type ThemeMode = "light" | "dark" | "lightblue"; export type ViewMode = "card" | "list"; export type LayoutMode = "full" | "compact"; +export type CompactShortcutParamType = "login" | "theme"; + +export interface CompactShortcutRouteParam { + key: string; + paramType: CompactShortcutParamType; + source?: string | null; + customValue?: string | null; + themeValue?: string | null; +} + +export interface CompactShortcutButton { + id: string; + label: string; + url: string; + route_params: CompactShortcutRouteParam[]; + enabled: boolean; + sort_order: number; +} export interface AppearanceSettings { default_theme: ThemeMode; default_skill_view: ViewMode; default_agent_view: ViewMode; default_layout_mode: LayoutMode; + compact_shortcut_buttons: CompactShortcutButton[]; } export interface AppearanceUpdate { @@ -189,6 +311,7 @@ export interface AppearanceUpdate { default_skill_view?: ViewMode; default_agent_view?: ViewMode; default_layout_mode?: LayoutMode; + compact_shortcut_buttons?: CompactShortcutButton[]; } export async function loadAppearanceSettings(): Promise { @@ -342,6 +465,18 @@ export interface SkillCompressionUpdate { keep_index_in_prompt?: boolean; } +export interface ModelConcurrencySettings { + enabled: boolean; + default_limit: number; + model_limits: Record; +} + +export interface ModelConcurrencyUpdate { + enabled?: boolean; + default_limit?: number; + model_limits?: Record; +} + export async function loadSkillCompressionSettings(): Promise { const response = await apiFetch( `${getBackendBaseURL()}/api/system-settings/skill-compression`, @@ -361,6 +496,53 @@ export async function loadSkillCompressionSettings(): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/model-concurrency`, + ); + if (!response.ok) { + throw new Error( + `Failed to load model concurrency settings: ${response.status}`, + ); + } + const data = (await response.json()) as ModelConcurrencySettings; + writeYLog({ + optType: OptType.view, + optDetail: "CM助手-全量-系统设置-模型并发限制查看", + url: "/api/system-settings/model-concurrency", + returnParam: data, + }); + return data; +} + +export async function updateModelConcurrencySettings( + body: ModelConcurrencyUpdate, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/model-concurrency`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const detail = await response + .json() + .catch(() => ({ detail: "Failed to update model concurrency settings" })); + throw new Error(detail.detail ?? "Failed to update model concurrency settings"); + } + const data = (await response.json()) as ModelConcurrencySettings; + writeYLog({ + optType: OptType.edit, + optDetail: "CM助手-全量-系统设置-模型并发限制编辑", + url: "/api/system-settings/model-concurrency", + reqParam: body, + returnParam: data, + }); + return data; +} + export async function updateSkillCompressionSettings( body: SkillCompressionUpdate, ): Promise { diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 0060b22a..b21902f7 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -7,6 +7,8 @@ import { loadAppearanceSettings, loadCitationDisplaySettings, loadLeaderboardSettings, + loadModelConcurrencySettings, + loadPluginManagementSettings, loadPrivateSquareStatus, loadPromptPrefixSettings, loadPublishSquares, @@ -15,6 +17,8 @@ import { updateAppearanceSettings, updateCitationDisplaySettings, updateLeaderboardSettings, + updateModelConcurrencySettings, + updatePluginManagementSettings, updatePrivateSquareSettings, updatePromptPrefixSettings, updatePublishSquares, @@ -22,6 +26,8 @@ import { type AppearanceUpdate, type CitationDisplayUpdate, type LeaderboardSettingsUpdate, + type ModelConcurrencyUpdate, + type PluginManagementUpdate, type PrivateSquareUpdate, type PromptPrefixUpdate, type PublishSquaresUpdate, @@ -37,6 +43,8 @@ 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; +const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const; +const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const; export function usePromptPrefixSettings() { return useQuery({ @@ -238,6 +246,48 @@ export function useUpdateSkillCompressionSettings() { }); } +export function useModelConcurrencySettings() { + return useQuery({ + queryKey: MODEL_CONCURRENCY_KEY, + queryFn: () => loadModelConcurrencySettings(), + staleTime: 60_000, + refetchOnWindowFocus: false, + retry: false, + }); +} + +export function useUpdateModelConcurrencySettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: ModelConcurrencyUpdate) => + updateModelConcurrencySettings(body), + onSuccess: (data) => { + queryClient.setQueryData(MODEL_CONCURRENCY_KEY, data); + }, + }); +} + +export function usePluginManagementSettings() { + return useQuery({ + queryKey: PLUGIN_MANAGEMENT_KEY, + queryFn: () => loadPluginManagementSettings(), + staleTime: 60_000, + refetchOnWindowFocus: false, + retry: false, + }); +} + +export function useUpdatePluginManagementSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: PluginManagementUpdate) => + updatePluginManagementSettings(body), + onSuccess: (data) => { + queryClient.setQueryData(PLUGIN_MANAGEMENT_KEY, data); + }, + }); +} + export function useLeaderboardSettings() { return useQuery({ queryKey: LEADERBOARD_KEY, diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index e3f725d8..a4284499 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -43,9 +43,13 @@ export type ThreadStreamOptions = { onToolEnd?: (event: ToolEndEvent) => void; }; -type SendMessageOptions = { - additionalKwargs?: Record; -}; +type SendMessageOptions = { + additionalKwargs?: Record; +}; + +type ThreadTitleRefreshState = Pick & { + title_provisional?: boolean; +}; function normalizeStoredRunId(runId: string | null): string | null { if (!runId) { @@ -155,7 +159,7 @@ export async function stopThreadRun( } } -function getStreamErrorMessage(error: unknown): string { +function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; } @@ -175,8 +179,31 @@ function getStreamErrorMessage(error: unknown): string { return nestedError; } } - return "Request failed."; -} + return "Request failed."; +} + +async function fetchThreadTitleRefreshState( + threadId: string, + signal?: AbortSignal, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/state`, + { signal }, + ); + if (!response.ok) { + return null; + } + const json = (await response.json()) as { + values?: { title?: string; title_provisional?: boolean }; + }; + if (!json.values) { + return null; + } + return { + title: json.values.title ?? "", + title_provisional: json.values.title_provisional, + }; +} /** * Detect "thread not found" / forbidden errors from the LangGraph SDK. @@ -224,15 +251,24 @@ function isRunNotFoundError(message: string): boolean { * 「already has an active run」。匹配 HTTP 409、英文兜底文案,或后端已中文化 * 的提示,统一映射成一句友好的中文。 */ -function isActiveRunConflictError(message: string): boolean { - return ( - /\b409\b/.test(message) || - /already has an active run/i.test(message) || - /还在生成回答/.test(message) - ); -} - -function buildCurrentUserLabel(): string { +function isActiveRunConflictError(message: string): boolean { + return ( + /\b409\b/.test(message) || + /already has an active run/i.test(message) || + /还在生成回答/.test(message) + ); +} + +function isModelConcurrencyLimitError(message: string): boolean { + return ( + /\b429\b/.test(message) || + /并发已达到上限/.test(message) || + /使用人数较多/.test(message) || + /too many requests/i.test(message) + ); +} + +function buildCurrentUserLabel(): string { const auth = getStoredAuth(); if (!auth) { return "未登录"; @@ -307,15 +343,24 @@ function lastHumanFromChatModelInput(input: unknown): string { if (Array.isArray(m)) m.forEach(push); else if (m != null) flat.push(m); }; - push(raw); - for (let i = flat.length - 1; i >= 0; i--) { - const m = flat[i]; - if (!m || typeof m !== "object") continue; - const type = (m as Record).type ?? (m as Record).role; - if (type === "human" || type === "user") { - const text = textFromChatModelOutput(m); - if (text) return text; - } + push(raw); + for (let i = flat.length - 1; i >= 0; i--) { + const m = flat[i]; + if (!m || typeof m !== "object") continue; + const obj = m as Record; + const type = obj.type ?? obj.role; + if (type === "human" || type === "user") { + const kwargs = obj.additional_kwargs; + if ( + kwargs && + typeof kwargs === "object" && + (kwargs as Record).hide_from_ui === true + ) { + continue; + } + const text = textFromChatModelOutput(m); + if (text) return text; + } } } catch { /* 取不到忽略 */ @@ -570,12 +615,16 @@ export function useThreadStream({ } // 上一轮还没结束又发了新问题(后端 409):给一句明确的中文提示,而不是 // 把原始的英文/HTTP 错误抛给用户。 - if (isActiveRunConflictError(message)) { - toast.info("当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。"); - return; - } - toast.error(message); - }, + if (isActiveRunConflictError(message)) { + toast.info("当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。"); + return; + } + if (isModelConcurrencyLimitError(message)) { + toast.info("当前使用人数较多,请稍等一下再试。"); + return; + } + toast.error(message); + }, onFinish(state) { listeners.current.onFinish?.(state.values); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); @@ -583,9 +632,12 @@ export function useThreadStream({ }); // Optimistic messages shown before the server stream responds - const [optimisticMessages, setOptimisticMessages] = useState([]); - const [isUploading, setIsUploading] = useState(false); - const sendInFlightRef = useRef(false); + const [optimisticMessages, setOptimisticMessages] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [threadValueOverrides, setThreadValueOverrides] = useState< + Partial + >({}); + const sendInFlightRef = useRef(false); // Carries the ask time of an in-flight send until the server-assigned human // message id shows up, so we can bind the timestamp to the real id (see the // reconciliation effect below). ``known`` is the set of human ids that @@ -613,12 +665,13 @@ export function useThreadStream({ // clearing optimistic messages in that case is exactly what made submitted // questions visually disappear — the live run that just started would be // aborted by the very effect intended to reset stale state. - useEffect(() => { - if (threadId != null) { - // Forward direction: starting or continuing with a real thread. - // No reset needed; the upstream useStream will load history for this id. - return; - } + useEffect(() => { + if (threadId != null) { + // Forward direction: starting or continuing with a real thread. + // No reset needed; the upstream useStream will load history for this id. + setThreadValueOverrides({}); + return; + } const prevThread = threadStreamRef.current; if (prevThread?.isLoading) { try { @@ -630,9 +683,88 @@ export function useThreadStream({ startedRef.current = false; sendInFlightRef.current = false; prevMsgCountRef.current = 0; - setOptimisticMessages([]); - setIsUploading(false); - }, [threadId]); + setOptimisticMessages([]); + setIsUploading(false); + setThreadValueOverrides({}); + }, [threadId]); + + useEffect(() => { + const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null; + const effectiveTitleProvisional = + (threadValueOverrides.title_provisional ?? + thread.values?.title_provisional) === true; + + if (!activeThreadId || thread.isLoading || !effectiveTitleProvisional) { + return; + } + + let cancelled = false; + let timeoutId: ReturnType | null = null; + const controller = new AbortController(); + + const poll = async () => { + while (!cancelled) { + try { + const nextState = await fetchThreadTitleRefreshState( + activeThreadId, + controller.signal, + ); + if (cancelled || !nextState) { + return; + } + + setThreadValueOverrides({ + title: nextState.title, + title_provisional: nextState.title_provisional, + }); + + if (nextState.title) { + updateCachedThreadLists(queryClient, (threads) => + threads.map((t) => + t.thread_id === activeThreadId + ? { + ...t, + values: { + ...t.values, + title: nextState.title, + title_provisional: nextState.title_provisional, + }, + } + : t, + ), + ); + } + + if (nextState.title_provisional !== true) { + void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); + return; + } + } catch { + return; + } + + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, 2000); + }); + } + }; + + void poll(); + + return () => { + cancelled = true; + controller.abort(); + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [ + onStreamThreadId, + queryClient, + thread.isLoading, + thread.values?.title_provisional, + threadValueOverrides.title_provisional, + ]); // Clear optimistic when server messages arrive (count increases) useEffect(() => { @@ -897,13 +1029,22 @@ export function useThreadStream({ ); // Merge thread with optimistic messages for display - const mergedThread = - optimisticMessages.length > 0 - ? ({ - ...thread, - messages: [...thread.messages, ...optimisticMessages], - } as typeof thread) - : thread; + const mergedValues = + Object.keys(threadValueOverrides).length > 0 + ? { + ...thread.values, + ...threadValueOverrides, + } + : thread.values; + + const mergedThread = + optimisticMessages.length > 0 || mergedValues !== thread.values + ? ({ + ...thread, + values: mergedValues, + messages: [...thread.messages, ...optimisticMessages], + } as typeof thread) + : thread; // 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。 // 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话 @@ -1271,28 +1412,29 @@ export function useRenameThread() { mutationFn: async ({ threadId, title, - }: { - threadId: string; - title: string; - }) => { - await apiClient.threads.updateState(threadId, { - values: { title }, - }); - }, - onSuccess(_, { threadId, title }) { - updateCachedThreadLists(queryClient, (threads) => - threads.map((t) => + }: { + threadId: string; + title: string; + }) => { + await apiClient.threads.updateState(threadId, { + values: { title, title_provisional: false }, + }); + }, + onSuccess(_, { threadId, title }) { + updateCachedThreadLists(queryClient, (threads) => + threads.map((t) => t.thread_id === threadId ? { ...t, - values: { - ...t.values, - title, - }, - } - : t, - ), - ); + values: { + ...t.values, + title, + title_provisional: false, + }, + } + : t, + ), + ); }, }); } diff --git a/frontend-web/src/core/threads/types.ts b/frontend-web/src/core/threads/types.ts index 905fd77a..56cfc515 100644 --- a/frontend-web/src/core/threads/types.ts +++ b/frontend-web/src/core/threads/types.ts @@ -2,12 +2,13 @@ import type { Message, Thread } from "@langchain/langgraph-sdk"; import type { Todo } from "../todos"; -export interface AgentThreadState extends Record { - title: string; - messages: Message[]; - artifacts: string[]; - todos?: Todo[]; -} +export interface AgentThreadState extends Record { + title: string; + title_provisional?: boolean; + messages: Message[]; + artifacts: string[]; + todos?: Todo[]; +} export interface AgentThreadContext extends Record { thread_id: string; 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 2fc03678..86732b7a 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -18,6 +18,7 @@ import type { AIWritingRequest } from '../../types/ai-writing'; import { useArticleTypes } from '../../hooks/useArticleTypes'; import { useWritingForm } from '../../contexts/WritingFormContext'; import { extractAIWritingSample } from '../../api/ai-writing-sample'; +import { ResearchTemplatesPanel } from './ResearchTemplatesPanel'; interface AIWritingFormProps { onSubmit: (req: AIWritingRequest) => void; @@ -583,6 +584,8 @@ export function AIWritingForm({ + + {!hideAdvancedFields && ( <>
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index ad13efd1..a61825a6 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { ChevronsDown, CloudUpload, PanelRightClose, Pause, Play, Plus, RotateCcw, Settings2, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; @@ -77,11 +77,13 @@ export function AIWritingPanel({ initialNotebookId, initialDocumentIds, studioToken, + headerActionsBeforeCollapse, }: { hideAdvancedFields?: boolean; initialNotebookId?: number; initialDocumentIds?: number[]; studioToken?: string; + headerActionsBeforeCollapse?: ReactNode; } = {}) { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -546,6 +548,7 @@ export function AIWritingPanel({ onRename={rename} onRefresh={refresh} /> + {headerActionsBeforeCollapse} {/* 收起写作配置:仅在 idle 配置阶段、表单展开时出现(与 AIWritingPage 的整块收起联动) */} {writingForm && isIdle && !isHistoryMode && writingForm.formExpanded && (
)}
-
-
- -
- - setTemplateSearch(event.target.value)} - placeholder="搜索写作模板..." - className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50/80 pl-8 pr-3 text-[12px] text-slate-600 outline-none transition focus:border-blue-200 focus:bg-white focus:ring-2 focus:ring-blue-100" - /> -
-
- - {templates.length === 0 ? ( -

- 暂无研究模板,点击标题配置 -

- ) : filteredTemplates.length === 0 ? ( -

- 暂无匹配模板 -

- ) : ( -
- {filteredTemplates.map((tpl, index) => { - const visual = getTemplateVisual(tpl, index); - const Icon = visual.Icon; - return ( - - ); - })} -
- )} -
- -
+

笔记本 diff --git a/frontend-web/src/open-canvas/components/ai-writing/ResearchTemplatesPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/ResearchTemplatesPanel.tsx new file mode 100644 index 00000000..ee101605 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/ResearchTemplatesPanel.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { + BarChart3, + BookOpen, + Globe2, + Newspaper, + Search, + Settings2, + SlidersHorizontal, + Sparkles, + TrendingUp, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { cn } from "@/lib/utils"; + +import type { ResearchTemplate } from "../../api/ai-writing-research-templates"; +import { useAIWriting } from "../../contexts/AIWritingContext"; +import { useWritingForm } from "../../contexts/WritingFormContext"; +import { useResearchTemplates } from "../../hooks/useResearchTemplates"; + +type TemplateVisual = { + Icon: LucideIcon; + iconClass: string; + accentClass: string; +}; + +const TEMPLATE_VISUALS: TemplateVisual[] = [ + { + Icon: Globe2, + iconClass: "bg-blue-50 text-blue-600", + accentClass: "bg-blue-500", + }, + { + Icon: Newspaper, + iconClass: "bg-amber-50 text-amber-600", + accentClass: "bg-amber-500", + }, + { + Icon: Sparkles, + iconClass: "bg-violet-50 text-violet-600", + accentClass: "bg-violet-500", + }, + { + Icon: TrendingUp, + iconClass: "bg-rose-50 text-rose-600", + accentClass: "bg-rose-500", + }, + { + Icon: BookOpen, + iconClass: "bg-emerald-50 text-emerald-600", + accentClass: "bg-emerald-500", + }, + { + Icon: BarChart3, + iconClass: "bg-cyan-50 text-cyan-600", + accentClass: "bg-cyan-500", + }, +]; + +function getTemplateVisual( + tpl: ResearchTemplate, + index: number, +): TemplateVisual { + const text = `${tpl.name} ${tpl.description ?? ""} ${tpl.article_type_key ?? ""}`; + if (/网摘|网络|热点|摘/.test(text)) return TEMPLATE_VISUALS[0]; + if (/周报|周刊|日报|月报/.test(text)) return TEMPLATE_VISUALS[1]; + if (/要讯|资讯|快讯|简报/.test(text)) return TEMPLATE_VISUALS[2]; + if (/行业|分析|市场|竞争/.test(text)) return TEMPLATE_VISUALS[3]; + if (/政策|解读|法规/.test(text)) return TEMPLATE_VISUALS[4]; + return TEMPLATE_VISUALS[index % TEMPLATE_VISUALS.length]; +} + +export function ResearchTemplatesPanel({ + className, +}: { + className?: string; +}) { + const navigate = useNavigate(); + const { templates, refresh } = useResearchTemplates(); + const { startWriting } = useAIWriting(); + const { + setIntent, + setArticleType, + setOutlineText, + setShowOutline, + setMaterialSource, + setWordCountInput, + setAudience, + setStrictMode, + setFormExpanded, + } = useWritingForm(); + const [templateSearch, setTemplateSearch] = useState(""); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const filteredTemplates = useMemo(() => { + const keyword = templateSearch.trim().toLowerCase(); + if (!keyword) return templates; + return templates.filter((tpl) => + [tpl.name, tpl.description, tpl.article_type_key] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(keyword)), + ); + }, [templates, templateSearch]); + + 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 ( +
+
+ +
+ + setTemplateSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") event.preventDefault(); + }} + placeholder="搜索写作模板..." + className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50/80 pl-8 pr-3 text-[12px] text-slate-600 outline-none transition focus:border-blue-200 focus:bg-white focus:ring-2 focus:ring-blue-100 dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]" + /> +
+
+ + {templates.length === 0 ? ( +

+ 暂无研究模板,点击标题配置 +

+ ) : filteredTemplates.length === 0 ? ( +

+ 暂无匹配模板 +

+ ) : ( +
+ {filteredTemplates.map((tpl, index) => { + const visual = getTemplateVisual(tpl, index); + const Icon = visual.Icon; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx index 1444a0fe..5210946b 100644 --- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx +++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx @@ -81,7 +81,7 @@ * -d '{"retention_days": 0}' * ────────────────────────────────────────────────────────────────────────── */ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { ArrowLeft, History, @@ -113,6 +113,10 @@ import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage'; import StudioNotebooksPage from '@/pages/StudioNotebooksPage'; import AgentChatPage from '@/pages/AgentChatPage'; import { ChatRuntime } from '@/pages/WorkspaceRoutes'; +import { + LayoutModeSwitcher, + ThemeSwitcher, +} from '@/components/workspace/appearance-controls'; import { AgentModuleSidebar, useAgentModuleSidebarData, @@ -137,10 +141,20 @@ const WRITING_ID = 'aw-writing'; const DRAFT_ID = 'aw-draft'; const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing'; const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体'; +const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`; // Match the original canvas page proportions (chat=35, artifact=65) const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 }; +function AIWritingCompactAppearanceActions() { + return ( +
+ + +
+ ); +} + function useAIWritingRouteBase() { const { pathname } = useLocation(); return useMemo(() => { @@ -170,7 +184,15 @@ function useAIWritingAgentRouteState(base: string) { function AIWritingAgentDefaultRedirect() { const { groups, isLoading } = useAgentModuleSidebarData(AI_WRITING_AGENT_MODULE_KEY); - const firstAgent = groups.flatMap((group) => group.agents)[0]; + const agents = groups.flatMap((group) => group.agents); + const lastAgentId = (() => { + try { + return sessionStorage.getItem(AI_WRITING_LAST_AGENT_KEY); + } catch { + return null; + } + })(); + const targetAgent = agents.find((agent) => agent.id === lastAgentId) ?? agents[0]; if (isLoading) { return ( @@ -180,7 +202,7 @@ function AIWritingAgentDefaultRedirect() { ); } - if (!firstAgent) { + if (!targetAgent) { return (
暂无可用智能体,请先在模块设置中配置或创建智能体。 @@ -188,24 +210,44 @@ function AIWritingAgentDefaultRedirect() { ); } - return ; + return ; } -function AIWritingAgentChatRoute() { +function AIWritingAgentChatRoute({ + compactAppearanceActions, +}: { + compactAppearanceActions?: ReactNode; +}) { const { agent_id } = useParams<{ agent_id: string }>(); return ( - + ); } -function AIWritingAgentRoutes({ base }: { base: string }) { +function AIWritingAgentRoutes({ + base, + compactAppearanceActions, +}: { + base: string; + compactAppearanceActions?: ReactNode; +}) { return ( } /> - } /> + + } + /> ); @@ -220,11 +262,26 @@ interface NotebookProps { studioToken?: string; } -function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) { +function WritingFormPanel({ + hideAdvancedFields, + initialNotebookId, + initialDocumentIds, + studioToken, + headerActionsBeforeCollapse, +}: { + hideAdvancedFields: boolean; + headerActionsBeforeCollapse?: ReactNode; +} & NotebookProps) { return ( // 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
- +
); } @@ -234,7 +291,16 @@ function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocume * - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单; * - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。 */ -function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) { +function AIWritingLayout({ + hideAdvancedFields, + initialNotebookId, + initialDocumentIds, + studioToken, + compactAppearanceActions, +}: { + hideAdvancedFields: boolean; + compactAppearanceActions?: ReactNode; +} & NotebookProps) { const layoutRef = useRef(null); const { state } = useAIWriting(); const { isHistoryMode } = useAIWritingHistory(); @@ -262,19 +328,28 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px] border-l' : 'w-0 min-w-0', )} > - +
{/* 收起态:右上角浮动展开图标按钮(与展开态头部的收起按钮位置呼应) */} {!formExpanded && ( - +
+ {compactAppearanceActions} + +
)}

); @@ -322,7 +397,16 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen * 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态: * 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。 */ -function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) { +function AIWritingBody({ + hideAdvancedFields, + initialNotebookId, + initialDocumentIds, + studioToken, + compactChrome = false, +}: { + hideAdvancedFields: boolean; + compactChrome?: boolean; +} & NotebookProps) { const { state } = useAIWriting(); const { isHistoryMode } = useAIWritingHistory(); const location = useLocation(); @@ -330,6 +414,8 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI const routeBase = useAIWritingRouteBase(); const agentRoute = useAIWritingAgentRouteState(routeBase); const isAdmin = getStoredAuth()?.system_role === 'admin'; + const externalNotebookMode = + studioToken != null || initialNotebookId != null || initialDocumentIds != null; // 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容 // (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。 @@ -348,12 +434,15 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI } }, [agentRoute.active]); - const agentsActive = sidebarTab === 'agents'; + const agentsActive = !externalNotebookMode && sidebarTab === 'agents'; // 研究模板侧栏在「写作配置(idle)」与「历史回看」两态都展示(点历史记录后保留侧栏、 // 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。 const showSidebar = - !studioToken && (state.status === 'idle' || isHistoryMode || agentsActive); + !externalNotebookMode && (state.status === 'idle' || isHistoryMode || agentsActive); + const compactAppearanceActions = compactChrome ? ( + + ) : null; return (
@@ -361,7 +450,7 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI {showSidebar && (
@@ -376,11 +465,11 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI onClick={() => setSidebarCollapsed(false)} title="展开侧栏" aria-label="展开侧栏" - className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700" + className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]" > - +