Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-dev
# Conflicts: # frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx # frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx # offline-backend-20260512/backend/CLAUDE.md # offline-backend-20260512/backend/app/gateway/services.py # offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py
This commit is contained in:
commit
221e947488
138 files changed
+65056
-2405
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronsUpDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
ListTree,
|
||||
MousePointerClick,
|
||||
Settings,
|
||||
@@ -231,6 +232,14 @@ function SecondaryNavSidebar() {
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||||
>
|
||||
<Gauge className="size-4" />
|
||||
模型并发限制
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
|
||||
className={cn(
|
||||
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
|
||||
isDark &&
|
||||
isActive &&
|
||||
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
|
||||
}}
|
||||
>
|
||||
<Bot className="size-[18px]" />
|
||||
<Bot className="size-[19px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[13px] font-semibold",
|
||||
isActive ? "text-primary" : "text-foreground",
|
||||
"block truncate text-[16px] leading-5 font-bold",
|
||||
isActive
|
||||
? isDark
|
||||
? "text-white"
|
||||
: "text-primary"
|
||||
: isDark
|
||||
? "text-white/95"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 block truncate text-[13px] leading-[1.35]",
|
||||
isDark
|
||||
? isActive
|
||||
? "text-[#d6eaff]"
|
||||
: "text-[#9ebfe2]"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{agent.description
|
||||
? desensitize(agent.description)
|
||||
: "智能体助手"}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
|
||||
isDark
|
||||
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
|
||||
: "bg-primary",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
<span className="text-sm font-semibold">智能体信息</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{headerActions}
|
||||
{compactAppearanceActions}
|
||||
{collapseButton}
|
||||
</div>
|
||||
</div>
|
||||
@@ -841,17 +845,20 @@ export function AgentDetailPanel({
|
||||
<>
|
||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
||||
{collapsed ? (
|
||||
<div className="hidden shrink-0 flex-col items-center border-l pt-3 lg:flex">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-label="展开智能体信息"
|
||||
>
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
||||
<div className="flex items-center gap-1">
|
||||
{compactAppearanceActions}
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-label="展开智能体信息"
|
||||
>
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<aside className="bg-card/40 hidden h-full w-[360px] shrink-0 flex-col border-l lg:flex">
|
||||
|
||||
@@ -211,34 +211,72 @@ export function AgentModuleSidebar({
|
||||
<Link
|
||||
key={`${keyPrefix}:${agent.id}`}
|
||||
href={href}
|
||||
onClick={() => {
|
||||
try {
|
||||
sessionStorage.setItem(`agent-module:${moduleKey}:last-agent`, agent.id);
|
||||
} catch {
|
||||
// Ignore storage failures; route navigation is the source of truth.
|
||||
}
|
||||
}}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
|
||||
className={cn(
|
||||
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
|
||||
isDark &&
|
||||
isActive &&
|
||||
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
|
||||
)}
|
||||
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
|
||||
>
|
||||
<Bot className="size-[18px]" />
|
||||
<Bot className="size-[19px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[13px] font-semibold",
|
||||
isActive ? "text-primary" : "text-foreground",
|
||||
"block truncate text-[16px] leading-5 font-bold",
|
||||
isActive
|
||||
? isDark
|
||||
? "text-white"
|
||||
: "text-primary"
|
||||
: isDark
|
||||
? "text-white/95"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 block truncate text-[13px] leading-[1.35]",
|
||||
isDark
|
||||
? isActive
|
||||
? "text-[#d6eaff]"
|
||||
: "text-[#9ebfe2]"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{agent.description ? desensitize(agent.description) : "智能体助手"}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
|
||||
isDark
|
||||
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
|
||||
: "bg-primary",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
|
||||
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -86,6 +87,7 @@ export function AgentSelector({
|
||||
};
|
||||
|
||||
const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
|
||||
const iconTooltipLabel = selectedAgent ? triggerLabel : "未选择";
|
||||
const iconMode = triggerVariant === "icon";
|
||||
|
||||
return (
|
||||
@@ -93,38 +95,40 @@ export function AgentSelector({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{iconMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
||||
className={cn(
|
||||
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
||||
selectedAgent && "text-amber-600 hover:text-amber-700",
|
||||
selectedAgent && iconActiveClassName,
|
||||
iconButtonClassName,
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-4" />
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip content={iconTooltipLabel}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
||||
className={cn(
|
||||
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
||||
selectedAgent && "text-amber-600 hover:text-amber-700",
|
||||
selectedAgent && iconActiveClassName,
|
||||
iconButtonClassName,
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-4" />
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
|
||||
import { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
|
||||
/**
|
||||
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
|
||||
@@ -39,6 +40,43 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
|
||||
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const buttons = (appearance?.compact_shortcut_buttons ?? [])
|
||||
.filter((button) => button.enabled && button.label.trim() && button.url.trim())
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
if (buttons.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{buttons.map((button) => (
|
||||
<Button
|
||||
key={button.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 max-w-[168px] shrink-0 gap-1.5 rounded-sm px-2 text-xs font-medium",
|
||||
triggerToneClass(tone),
|
||||
)}
|
||||
title={button.label}
|
||||
onClick={() => {
|
||||
const url = buildLightAppUrl({
|
||||
url: button.url,
|
||||
routeParams: button.route_params.map((param) => ({ ...param })),
|
||||
});
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-4 shrink-0" />
|
||||
<span className="truncate">{button.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -136,9 +174,11 @@ const LAYOUT_OPTIONS = [
|
||||
export function LayoutModeSwitcher({
|
||||
tone,
|
||||
fallback = "compact",
|
||||
navigateTo = "/page/workspace/chats/new",
|
||||
}: {
|
||||
tone: AppearanceControlTone;
|
||||
fallback?: "full" | "compact";
|
||||
navigateTo?: string | null;
|
||||
}) {
|
||||
const [mode, setLayoutMode] = useLayoutMode();
|
||||
const navigate = useNavigate();
|
||||
@@ -170,7 +210,9 @@ export function LayoutModeSwitcher({
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
setLayoutMode(option.id);
|
||||
navigate("/page/workspace/chats/new");
|
||||
if (navigateTo) {
|
||||
navigate(navigateTo);
|
||||
}
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
ArtifactHeader,
|
||||
ArtifactTitle,
|
||||
} from "@/components/ai-elements/artifact";
|
||||
import { Select, SelectItem } from "@/components/ui/select";
|
||||
import { Select, SelectItem } from "@/components/ui/select";
|
||||
import {
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
@@ -41,7 +41,8 @@ import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
|
||||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
} from "@/core/artifacts/download";
|
||||
@@ -51,9 +52,8 @@ import {
|
||||
} from "@/core/artifacts/hooks";
|
||||
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import {
|
||||
checkCodeFile,
|
||||
getFileName,
|
||||
@@ -81,7 +81,8 @@ import { useThread } from "../messages/context";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { useArtifacts } from "./context";
|
||||
import { SpreadsheetPreview } from "./spreadsheet-preview";
|
||||
import { SpreadsheetPreview } from "./spreadsheet-preview";
|
||||
import { SkillInstallDialog } from "./skill-install-dialog";
|
||||
|
||||
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
||||
|
||||
@@ -238,7 +239,7 @@ export function ArtifactFileDetail({
|
||||
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview" | "edit">("code");
|
||||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
|
||||
@@ -439,29 +440,7 @@ export function ArtifactFileDetail({
|
||||
}, 1600);
|
||||
}, [scrollArtifactToBottom]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
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 {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
|
||||
const handleSaveArtifact = useCallback(async () => {
|
||||
const handleSaveArtifact = useCallback(async () => {
|
||||
if (!canEditArtifact || updateArtifact.isPending) {
|
||||
return;
|
||||
}
|
||||
@@ -704,18 +683,17 @@ export function ArtifactFileDetail({
|
||||
)}
|
||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||
label={t.common.install}
|
||||
tooltip={t.common.install}
|
||||
disabled={
|
||||
isInstalling ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
onClick={handleInstallSkill}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={PackageIcon}
|
||||
label={t.common.install}
|
||||
tooltip={t.common.install}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
onClick={() => setInstallDialogOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isCodeFile && (
|
||||
<ArtifactAction
|
||||
icon={CopyIcon}
|
||||
@@ -723,7 +701,7 @@ export function ArtifactFileDetail({
|
||||
disabled={!content}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayContent ?? "");
|
||||
await copyTextToClipboard(displayContent ?? "");
|
||||
toast.success(t.clipboard.copiedToClipboard);
|
||||
} catch (error) {
|
||||
toast.error("Failed to copy to clipboard");
|
||||
@@ -733,7 +711,7 @@ export function ArtifactFileDetail({
|
||||
tooltip={t.clipboard.copyToClipboard}
|
||||
/>
|
||||
)}
|
||||
{!useWriteFileStreaming && language === "markdown" && (
|
||||
{language === "markdown" && (
|
||||
<ArtifactAction
|
||||
icon={isExportingWord ? LoaderIcon : FileType2Icon}
|
||||
label="另存为 Word"
|
||||
@@ -800,7 +778,7 @@ export function ArtifactFileDetail({
|
||||
</ArtifactActions>
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent
|
||||
<ArtifactContent
|
||||
ref={artifactContentRef}
|
||||
className="relative p-0"
|
||||
onWheelCapture={handlePauseAutoScroll}
|
||||
@@ -888,10 +866,16 @@ export function ArtifactFileDetail({
|
||||
{t.common.loading}
|
||||
</div>
|
||||
))}
|
||||
</ArtifactContent>
|
||||
</Artifact>
|
||||
);
|
||||
}
|
||||
</ArtifactContent>
|
||||
<SkillInstallDialog
|
||||
open={installDialogOpen}
|
||||
onOpenChange={setInstallDialogOpen}
|
||||
threadId={threadId}
|
||||
filepath={filepath}
|
||||
/>
|
||||
</Artifact>
|
||||
);
|
||||
}
|
||||
|
||||
function SandboxPreviewImage({
|
||||
src,
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||
const { t } = useI18n();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||
const [installFile, setInstallFile] = useState<string | null>(null);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(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
|
||||
</CardDescription>
|
||||
<CardAction className="row-span-1 self-center">
|
||||
{file.endsWith(".skill") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={installingFile === file}
|
||||
onClick={(e) => handleInstallSkill(e, file)}
|
||||
>
|
||||
{installingFile === file ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PackageIcon className="size-4" />
|
||||
)}
|
||||
{t.common.install}
|
||||
</Button>
|
||||
)}
|
||||
{file.endsWith(".skill") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => handleInstallSkill(e, file)}
|
||||
>
|
||||
<PackageIcon className="size-4" />
|
||||
{t.common.install}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={downloadingFile === file}
|
||||
@@ -142,7 +118,15 @@ export function ArtifactFileList({
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
))}
|
||||
<SkillInstallDialog
|
||||
open={!!installFile}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setInstallFile(null);
|
||||
}}
|
||||
threadId={threadId}
|
||||
filepath={installFile}
|
||||
/>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircleIcon, Loader2Icon, PackageIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
installSkill,
|
||||
previewSkillInstall,
|
||||
type PreviewSkillInstallResponse,
|
||||
} from "@/core/skills/api";
|
||||
|
||||
export function SkillInstallDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
threadId,
|
||||
filepath,
|
||||
onInstalled,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
threadId: string;
|
||||
filepath: string | null;
|
||||
onInstalled?: (skillName: string) => void;
|
||||
}) {
|
||||
const [preview, setPreview] = useState<PreviewSkillInstallResponse | null>(null);
|
||||
const [installName, setInstallName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !filepath) {
|
||||
setPreview(null);
|
||||
setInstallName("");
|
||||
setLoading(false);
|
||||
setInstalling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setPreview(null);
|
||||
setInstallName("");
|
||||
|
||||
void previewSkillInstall({ thread_id: threadId, path: filepath })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setPreview(result);
|
||||
setInstallName(result.skill_name);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
toast.error(error instanceof Error ? error.message : "Failed to preview skill install");
|
||||
onOpenChange(false);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filepath, onOpenChange, open, threadId]);
|
||||
|
||||
const trimmedName = installName.trim();
|
||||
const unchangedConflict = useMemo(() => {
|
||||
if (!preview) return false;
|
||||
return trimmedName === preview.skill_name && !preview.can_install_as_is;
|
||||
}, [preview, trimmedName]);
|
||||
|
||||
async function handleInstall() {
|
||||
if (!filepath || !trimmedName || installing) return;
|
||||
setInstalling(true);
|
||||
try {
|
||||
const result = await installSkill({
|
||||
thread_id: threadId,
|
||||
path: filepath,
|
||||
skill_name: trimmedName,
|
||||
});
|
||||
if (!result.success) {
|
||||
toast.error(result.message || "Failed to install skill");
|
||||
return;
|
||||
}
|
||||
toast.success(result.message);
|
||||
onInstalled?.(result.skill_name);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to install skill");
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
const helperText = preview?.owned_by_other
|
||||
? `该技能名已被 ${preview.owner_username || "其他用户"} 安装。你可以换个名字继续安装。`
|
||||
: preview?.owned_by_me
|
||||
? "你已经安装过这个技能了;如果想保留一份新副本,可以换个名字安装。"
|
||||
: preview?.builtin_conflict
|
||||
? "这个名字已被公共技能占用,请换个名字安装。"
|
||||
: "可以直接安装,也可以先改一个更适合自己的名字。";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>安装技能</DialogTitle>
|
||||
<DialogDescription>安装前会校验技能归属;如果名字已被占用,可以改名后继续安装。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在检查技能安装权限…
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border bg-muted/35 p-3 text-sm">
|
||||
<div className="font-medium">{preview.skill_name}</div>
|
||||
{preview.description ? (
|
||||
<div className="text-muted-foreground mt-1 line-clamp-3">{preview.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">安装名称</div>
|
||||
<Input
|
||||
value={installName}
|
||||
onChange={(e) => setInstallName(e.currentTarget.value)}
|
||||
placeholder="请输入技能名称"
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">{helperText}</div>
|
||||
</div>
|
||||
|
||||
{!preview.can_install_as_is && (
|
||||
<div className="flex gap-2 rounded-xl border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
<AlertCircleIcon className="mt-0.5 size-4 shrink-0" />
|
||||
<div>{preview.message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.warnings.length > 0 && (
|
||||
<div className="text-muted-foreground rounded-xl border px-3 py-2 text-xs">
|
||||
{preview.warnings.join(" ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={installing}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleInstall()}
|
||||
disabled={loading || installing || !trimmedName || unchangedConflict}
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PackageIcon className="size-4" />
|
||||
)}
|
||||
安装
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof Button> & {
|
||||
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 (
|
||||
<Tooltip content={t.clipboard.copyToClipboard}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
|
||||
@@ -349,33 +349,64 @@ export function GeneralQaSidebar() {
|
||||
type="button"
|
||||
onClick={() => handleSelectAgent(agent.id)}
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 text-left transition-colors",
|
||||
"group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 text-left 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",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
|
||||
className={cn(
|
||||
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
|
||||
isDark &&
|
||||
isActive &&
|
||||
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
|
||||
)}
|
||||
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
|
||||
>
|
||||
<Bot className="size-[18px]" />
|
||||
<Bot className="size-[19px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[13px] font-semibold",
|
||||
isActive ? "text-primary" : "text-foreground",
|
||||
"block truncate text-[16px] leading-5 font-bold",
|
||||
isActive
|
||||
? isDark
|
||||
? "text-white"
|
||||
: "text-primary"
|
||||
: isDark
|
||||
? "text-white/95"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 block truncate text-[13px] leading-[1.35]",
|
||||
isDark
|
||||
? isActive
|
||||
? "text-[#d6eaff]"
|
||||
: "text-[#9ebfe2]"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{agent.description ? desensitize(agent.description) : "智能体助手"}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
|
||||
isDark
|
||||
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
|
||||
: "bg-primary",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -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<CompactShortcutButton[]>([]);
|
||||
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<CompactShortcutButton>,
|
||||
) => {
|
||||
setShortcutDraft((items) =>
|
||||
items.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
const patchShortcutParam = (
|
||||
id: string,
|
||||
index: number,
|
||||
patch: Partial<CompactShortcutRouteParam>,
|
||||
) => {
|
||||
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 }) {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认界面模式(管理员)"
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认界面模式(管理员)"
|
||||
description="给所有还没有手动切换过界面模式的用户的默认;已经自己切换过的用户保留其个人选择不受影响。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -257,13 +475,47 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
{globalLayoutMode === "full" ? "全量模式" : "简洁模式"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="顶部快捷按钮(管理员)"
|
||||
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
|
||||
>
|
||||
<CompactShortcutSettings
|
||||
buttons={shortcutDraft}
|
||||
saving={updateAppearance.isPending}
|
||||
onAdd={() =>
|
||||
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}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认主题(管理员)"
|
||||
description="给所有还没有手动切换过主题的用户的默认主题;已经自己切换过的用户保留其个人设置不受影响。"
|
||||
@@ -515,12 +767,304 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<CompactShortcutButton>) => void;
|
||||
onMove: (id: string, direction: -1 | 1) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onAddParam: (id: string) => void;
|
||||
onPatchParam: (
|
||||
id: string,
|
||||
index: number,
|
||||
patch: Partial<CompactShortcutRouteParam>,
|
||||
) => void;
|
||||
onRemoveParam: (id: string, index: number) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
添加按钮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
>
|
||||
保存按钮配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{buttons.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm">
|
||||
暂无按钮,点击“添加按钮”创建一个。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{buttons.map((button, index) => (
|
||||
<div key={button.id} className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-start">
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<Switch
|
||||
checked={button.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onPatch(button.id, { enabled: checked })
|
||||
}
|
||||
aria-label="启用快捷按钮"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{button.enabled ? "启用" : "停用"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium">按钮名称</div>
|
||||
<Input
|
||||
value={button.label}
|
||||
onChange={(event) =>
|
||||
onPatch(button.id, { label: event.target.value })
|
||||
}
|
||||
placeholder="如:业务系统"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium">跳转链接</div>
|
||||
<Input
|
||||
value={button.url}
|
||||
onChange={(event) =>
|
||||
onPatch(button.id, { url: event.target.value })
|
||||
}
|
||||
placeholder="https://example.com/page"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onMove(button.id, -1)}
|
||||
disabled={index === 0}
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onMove(button.id, 1)}
|
||||
disabled={index === buttons.length - 1}
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDown className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onRemove(button.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<ExternalLink className="size-4" />
|
||||
路由参数
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAddParam(button.id)}
|
||||
>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
添加参数
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{button.route_params.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
|
||||
不追加额外参数。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{button.route_params.map((param, paramIndex) => {
|
||||
const themeMap =
|
||||
param.paramType === "theme"
|
||||
? parseThemeMap(param.themeValue)
|
||||
: {};
|
||||
return (
|
||||
<div
|
||||
key={`${button.id}-${paramIndex}`}
|
||||
className="space-y-2 rounded-md border bg-background p-2"
|
||||
>
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="参数名 key"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Select
|
||||
value={param.paramType}
|
||||
onValueChange={(value) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
paramType: value as "login" | "theme",
|
||||
source:
|
||||
value === "login"
|
||||
? param.source || "access_token"
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="login">登录参数</SelectItem>
|
||||
<SelectItem value="theme">主题参数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{param.paramType === "login" ? (
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Select
|
||||
value={param.source || "access_token"}
|
||||
onValueChange={(value) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
source: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-0 flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOGIN_SOURCE_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{param.source === "other" && (
|
||||
<Input
|
||||
value={param.customValue ?? ""}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
customValue: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="自定义值"
|
||||
className="w-32 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center text-xs">
|
||||
按当前主题取值
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() =>
|
||||
onRemoveParam(button.id, paramIndex)
|
||||
}
|
||||
title="删除参数"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{param.paramType === "theme" && (
|
||||
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
|
||||
{APP_THEME_COLORS.map((color) => (
|
||||
<label
|
||||
key={color.key}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: color.dot }}
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{color.label}
|
||||
</span>
|
||||
<Input
|
||||
value={themeMap[color.key] ?? color.key}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
themeValue: stringifyThemeMap({
|
||||
...themeMap,
|
||||
[color.key]: event.target.value,
|
||||
}),
|
||||
})
|
||||
}
|
||||
placeholder={color.key}
|
||||
className="h-8 min-w-0 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreviewCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
active,
|
||||
mode,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<Breadcrumb>
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{segments?.[0] && (
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
@@ -88,11 +91,14 @@ export function WorkspaceHeader({
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{rightActions ? (
|
||||
<div className="flex items-center gap-2 px-4">{rightActions}</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceBody({
|
||||
className,
|
||||
|
||||
@@ -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<typeof useI18n>["t"];
|
||||
}) {
|
||||
return isSidebarOpen ? (
|
||||
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
|
||||
<SettingsIcon className="size-4" />
|
||||
<span>{t.workspace.settingsAndMore}</span>
|
||||
<ChevronsUpDown className="text-sidebar-foreground/55 ml-auto size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<SettingsIcon className="text-sidebar-foreground/80 size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<LogSystemSettings | null>(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 (
|
||||
<>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<SidebarMenu className="w-full">
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||
<BellIcon />
|
||||
{t.notifications.title}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("appearance");
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings2Icon />
|
||||
{t.common.settings}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/llm-metrics")
|
||||
}
|
||||
>
|
||||
<GaugeIcon />
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/checkpoint-migration")
|
||||
}
|
||||
>
|
||||
<DatabaseZapIcon />
|
||||
Checkpoint 迁移
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/scheduled-tasks")
|
||||
}
|
||||
>
|
||||
<CalendarClockIcon />
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/agents")}
|
||||
>
|
||||
<BotIcon />
|
||||
智能体管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skills")}
|
||||
>
|
||||
<SparklesIcon />
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/positions")}
|
||||
>
|
||||
<BriefcaseIcon />
|
||||
岗位管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate(
|
||||
"/page/workspace/settings/recommended-questions",
|
||||
)
|
||||
}
|
||||
>
|
||||
<MessageCircleQuestionIcon />
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
window.open(
|
||||
logSystem.url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ScrollTextIcon />
|
||||
{logSystem.label || "日志系统"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<SidebarMenuButton size="lg" className="pointer-events-none">
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
"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<typeof useI18n>["t"];
|
||||
}) {
|
||||
return isSidebarOpen ? (
|
||||
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
|
||||
<SettingsIcon className="size-4" />
|
||||
<span>{t.workspace.settingsAndMore}</span>
|
||||
<ChevronsUpDown className="text-sidebar-foreground/55 ml-auto size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<SettingsIcon className="text-sidebar-foreground/80 size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<LogSystemSettings | null>(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 (
|
||||
<>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<SidebarMenu className="w-full">
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||
<BellIcon />
|
||||
{t.notifications.title}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("appearance");
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings2Icon />
|
||||
{t.common.settings}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/llm-metrics")
|
||||
}
|
||||
>
|
||||
<GaugeIcon />
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/checkpoint-migration")
|
||||
}
|
||||
>
|
||||
<DatabaseZapIcon />
|
||||
Checkpoint 迁移
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/scheduled-tasks")
|
||||
}
|
||||
>
|
||||
<CalendarClockIcon />
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/agents")}
|
||||
>
|
||||
<BotIcon />
|
||||
智能体管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skills")}
|
||||
>
|
||||
<SparklesIcon />
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/plugins")}
|
||||
>
|
||||
<PlugIcon />
|
||||
插件管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/positions")}
|
||||
>
|
||||
<BriefcaseIcon />
|
||||
岗位管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate(
|
||||
"/page/workspace/settings/recommended-questions",
|
||||
)
|
||||
}
|
||||
>
|
||||
<MessageCircleQuestionIcon />
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
window.open(
|
||||
logSystem.url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ScrollTextIcon />
|
||||
{logSystem.label || "日志系统"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<SidebarMenuButton size="lg" className="pointer-events-none">
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<SqReportStatus> {
|
||||
if (!taskId) return "unknown";
|
||||
export async function fetchSqReportCheck(taskId: string): Promise<SqReportCheckResult> {
|
||||
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<SqReportStatus> {
|
||||
return (await fetchSqReportCheck(taskId)).status;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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, unknown>): string {
|
||||
return normalizeExternalAccessToken(userInfo.access_token) || normalizeExternalAccessToken(userInfo.accessToken);
|
||||
}
|
||||
|
||||
export function readCurrentExternalAccessTokenValue(userInfo: Record<string, unknown>): string {
|
||||
return readStoredExternalAccessTokenValue() || readExternalAccessTokenValue(userInfo);
|
||||
}
|
||||
|
||||
export function writeExternalAccessTokenFields(
|
||||
userInfo: Record<string, unknown>,
|
||||
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<string, unknown>): void {
|
||||
delete userInfo.access_token;
|
||||
delete userInfo.accessToken;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredYUserId(info: Record<string, unknown>): 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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>): void {
|
||||
if (typeof console === "undefined") return;
|
||||
console.info(`[yUserId-login] ${step}`, payload);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
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<string, Record<string, unknown>> {
|
||||
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<string, Record<string, unknown>> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (isPlainObject(value)) cache[key] = value;
|
||||
}
|
||||
return cache;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedYUserInfo(yUserId: string): Record<string, unknown> {
|
||||
return readYUserInfoCache()[yUserId] ?? {};
|
||||
}
|
||||
|
||||
function writeCachedYUserInfo(yUserId: string, info: Record<string, unknown>): 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<string, unknown>,
|
||||
access_token: string | null | undefined,
|
||||
previous: Record<string, unknown>,
|
||||
cached: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...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<void> {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -588,7 +588,7 @@ function replaceBracketCitationTokens(
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
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<number, RuntimeDisplaySource>,
|
||||
@@ -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<string, unknown>): 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<string> {
|
||||
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<string, unknown> =>
|
||||
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<string, unknown> =>
|
||||
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;
|
||||
|
||||
@@ -141,17 +141,39 @@ export async function getSkillDisplaySample(
|
||||
return response.json() as Promise<SkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
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<InstallSkillResponse> {
|
||||
export async function installSkill(
|
||||
request: InstallSkillRequest,
|
||||
): Promise<InstallSkillResponse> {
|
||||
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<PreviewSkillInstallResponse> {
|
||||
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<PreviewSkillInstallResponse>;
|
||||
}
|
||||
|
||||
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
|
||||
/** 当前登录态 accessToken(无则空串)。 */
|
||||
export function getAccessToken(): string {
|
||||
const value = readUserInfo().accessToken;
|
||||
return value == null ? "" : String(value);
|
||||
return readCurrentExternalAccessTokenValue(readUserInfo());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<PluginManagementSettings> {
|
||||
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<PluginManagementSettings> {
|
||||
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<PromptPrefixSettings> {
|
||||
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<AppearanceSettings> {
|
||||
@@ -342,6 +465,18 @@ export interface SkillCompressionUpdate {
|
||||
keep_index_in_prompt?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelConcurrencySettings {
|
||||
enabled: boolean;
|
||||
default_limit: number;
|
||||
model_limits: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ModelConcurrencyUpdate {
|
||||
enabled?: boolean;
|
||||
default_limit?: number;
|
||||
model_limits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
||||
@@ -361,6 +496,53 @@ export async function loadSkillCompressionSettings(): Promise<SkillCompressionSe
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadModelConcurrencySettings(): Promise<ModelConcurrencySettings> {
|
||||
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<ModelConcurrencySettings> {
|
||||
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<SkillCompressionSettings> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,9 +43,13 @@ export type ThreadStreamOptions = {
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
|
||||
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<ThreadTitleRefreshState | null> {
|
||||
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<string, unknown>).type ?? (m as Record<string, unknown>).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<string, unknown>;
|
||||
const type = obj.type ?? obj.role;
|
||||
if (type === "human" || type === "user") {
|
||||
const kwargs = obj.additional_kwargs;
|
||||
if (
|
||||
kwargs &&
|
||||
typeof kwargs === "object" &&
|
||||
(kwargs as Record<string, unknown>).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<Message[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const sendInFlightRef = useRef(false);
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [threadValueOverrides, setThreadValueOverrides] = useState<
|
||||
Partial<ThreadTitleRefreshState>
|
||||
>({});
|
||||
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<typeof setTimeout> | 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<void>((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,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Todo } from "../todos";
|
||||
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
title_provisional?: boolean;
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
|
||||
export interface AgentThreadContext extends Record<string, unknown> {
|
||||
thread_id: string;
|
||||
|
||||
@@ -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({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResearchTemplatesPanel />
|
||||
|
||||
{!hideAdvancedFields && (
|
||||
<>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3">
|
||||
|
||||
@@ -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 && (
|
||||
<Button
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Eye,
|
||||
FileText,
|
||||
Globe2,
|
||||
History,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
Newspaper,
|
||||
PanelLeftClose,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -38,64 +28,11 @@ import { createSearchSpace } from "@/core/studio/api";
|
||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useResearchTemplates } from "../../hooks/useResearchTemplates";
|
||||
import { useAIWritingSessions } from "../../hooks/useAIWritingSessions";
|
||||
import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
|
||||
import type { AIWritingSessionSummary } from "../../api/ai-writing-sessions";
|
||||
import { useWritingForm } from "../../contexts/WritingFormContext";
|
||||
import { useAIWriting } from "../../contexts/AIWritingContext";
|
||||
import { useAIWritingHistory } from "../../contexts/AIWritingHistoryContext";
|
||||
import { formatRelative } from "./AIWritingHistoryDropdown";
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
// 历史记录状态药丸样式(对齐多智能体会商的历史记录观感)。
|
||||
// 注:`in_progress` 不再转圈显示「进行中」——刷新=重置后这些会话已无任何活进程,
|
||||
// 转圈会误导用户以为还在跑;统一显示为中性、不动的「未完成」。真正还在跑的是 `background`
|
||||
@@ -237,25 +174,11 @@ export function ResearchSidebar({
|
||||
/** 收起侧栏(由页面承载收起态)。 */
|
||||
onCollapse?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { templates, refresh } = useResearchTemplates();
|
||||
const { startWriting } = useAIWriting();
|
||||
const {
|
||||
notebooks,
|
||||
isLoading: notebooksLoading,
|
||||
isError: notebooksError,
|
||||
} = useStudioNotebooks();
|
||||
const {
|
||||
setIntent,
|
||||
setArticleType,
|
||||
setOutlineText,
|
||||
setShowOutline,
|
||||
setMaterialSource,
|
||||
setWordCountInput,
|
||||
setAudience,
|
||||
setStrictMode,
|
||||
setFormExpanded,
|
||||
} = useWritingForm();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 「历史记录」列表(左下角,样式参考多智能体会商的历史记录):自带一份会话列表,
|
||||
@@ -283,26 +206,11 @@ export function ResearchSidebar({
|
||||
window.dispatchEvent(new CustomEvent("ai-writing:show-progress", { detail: session }));
|
||||
};
|
||||
|
||||
const [templateSearch, setTemplateSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = 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 handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -323,39 +231,6 @@ export function ResearchSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateClick = (tpl: ResearchTemplate) => {
|
||||
const intent = tpl.topic?.trim() ?? "";
|
||||
const articleType = tpl.article_type_key || "analysis";
|
||||
const outline = tpl.outline?.trim() || "";
|
||||
const audience = tpl.audience?.trim() || "普通读者";
|
||||
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
|
||||
const writingMode = tpl.strict_mode ? "strict" : "loose";
|
||||
|
||||
if (intent) setIntent(intent);
|
||||
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
|
||||
if (outline) {
|
||||
setOutlineText(outline);
|
||||
setShowOutline(true);
|
||||
}
|
||||
setMaterialSource(tpl.material_source);
|
||||
setWordCountInput(String(wordCount));
|
||||
if (tpl.audience?.trim()) setAudience(audience);
|
||||
setStrictMode(tpl.strict_mode);
|
||||
setFormExpanded(true);
|
||||
|
||||
if (!intent) return;
|
||||
void startWriting({
|
||||
userIntent: intent,
|
||||
userOutline: outline || undefined,
|
||||
articleType,
|
||||
targetAudience: audience,
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: 1,
|
||||
writingMode,
|
||||
materialSource: tpl.material_source,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="ai-research-sidebar flex h-full w-[320px] shrink-0 flex-col overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)]">
|
||||
{onCollapse && (
|
||||
@@ -373,91 +248,7 @@ export function ResearchSidebar({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4">
|
||||
<section className="mb-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/canvas/research-templates")}
|
||||
title="配置研究模板"
|
||||
aria-label="配置研究模板"
|
||||
className="group flex items-center gap-2 rounded-lg text-[16px] font-bold text-slate-500 transition-colors hover:text-slate-800"
|
||||
>
|
||||
<SlidersHorizontal className="size-4 text-slate-400" />
|
||||
<span>研究模板</span>
|
||||
<Settings2 className="size-3.5 text-slate-300 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
<div className="relative w-[150px]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={templateSearch}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
|
||||
暂无研究模板,点击标题配置
|
||||
</p>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
|
||||
暂无匹配模板
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredTemplates.map((tpl, index) => {
|
||||
const visual = getTemplateVisual(tpl, index);
|
||||
const Icon = visual.Icon;
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplateClick(tpl)}
|
||||
title={tpl.description ?? tpl.name}
|
||||
className="group relative flex w-full items-start gap-3 overflow-hidden rounded-[5px] bg-white px-4 py-4 text-left shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(30,64,175,0.08)] active:translate-y-0"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-12 shrink-0 items-center justify-center rounded-xl transition-colors",
|
||||
visual.iconClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6 stroke-[2.2]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 pt-0.5">
|
||||
<span className="block truncate text-[16px] font-bold leading-5 text-slate-900 transition-colors group-hover:text-blue-700">
|
||||
{tpl.name}
|
||||
</span>
|
||||
{tpl.description && (
|
||||
<span
|
||||
className="mt-1.5 overflow-hidden text-[13px] leading-6 text-slate-500"
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2,
|
||||
}}
|
||||
>
|
||||
{tpl.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-4 left-0 top-4 w-1 rounded-r-full opacity-0 transition-opacity group-hover:opacity-100",
|
||||
visual.accentClass,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col border-t border-slate-100 pt-5">
|
||||
<section className="flex flex-col">
|
||||
<div className="mb-3 flex items-center justify-between px-2">
|
||||
<h2 className="text-[15px] font-bold tracking-wide text-slate-400">
|
||||
笔记本
|
||||
|
||||
@@ -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 (
|
||||
<section
|
||||
className={cn(
|
||||
"rounded-2xl border border-[#ece5e4] bg-card p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/canvas/research-templates")}
|
||||
title="配置研究模板"
|
||||
aria-label="配置研究模板"
|
||||
className="group flex items-center gap-2 rounded-lg text-[16px] font-bold text-slate-600 transition-colors hover:text-slate-900 dark:text-white"
|
||||
>
|
||||
<SlidersHorizontal className="size-4 text-slate-400 dark:text-white/70" />
|
||||
<span>研究模板</span>
|
||||
<Settings2 className="size-3.5 text-slate-300 opacity-0 transition-opacity group-hover:opacity-100 dark:text-white/60" />
|
||||
</button>
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-[180px]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={templateSearch}
|
||||
onChange={(event) => 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]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
|
||||
暂无研究模板,点击标题配置
|
||||
</p>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
|
||||
暂无匹配模板
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredTemplates.map((tpl, index) => {
|
||||
const visual = getTemplateVisual(tpl, index);
|
||||
const Icon = visual.Icon;
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplateClick(tpl)}
|
||||
title={tpl.description ?? tpl.name}
|
||||
className="group relative flex w-full items-start gap-3 overflow-hidden rounded-[5px] bg-white px-4 py-4 text-left shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(30,64,175,0.08)] active:translate-y-0 dark:bg-[#032650]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-12 shrink-0 items-center justify-center rounded-xl transition-colors",
|
||||
visual.iconClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6 stroke-[2.2]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 pt-0.5">
|
||||
<span className="block truncate text-[16px] font-bold leading-5 text-slate-900 transition-colors group-hover:text-blue-700 dark:text-white">
|
||||
{tpl.name}
|
||||
</span>
|
||||
{tpl.description && (
|
||||
<span
|
||||
className="mt-1.5 overflow-hidden text-[13px] leading-6 text-slate-500 dark:text-[#AACEFF]/75"
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2,
|
||||
}}
|
||||
>
|
||||
{tpl.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-4 left-0 top-4 w-1 rounded-r-full opacity-0 transition-opacity group-hover:opacity-100",
|
||||
visual.accentClass,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex size-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||
暂无可用智能体,请先在模块设置中配置或创建智能体。
|
||||
@@ -188,24 +210,44 @@ function AIWritingAgentDefaultRedirect() {
|
||||
);
|
||||
}
|
||||
|
||||
return <Navigate to={`agents/${firstAgent.id}/chats/new`} replace />;
|
||||
return <Navigate to={`agents/${targetAgent.id}/chats/new`} replace />;
|
||||
}
|
||||
|
||||
function AIWritingAgentChatRoute() {
|
||||
function AIWritingAgentChatRoute({
|
||||
compactAppearanceActions,
|
||||
}: {
|
||||
compactAppearanceActions?: ReactNode;
|
||||
}) {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
return (
|
||||
<ChatRuntime>
|
||||
<AgentChatPage key={agent_id} />
|
||||
<AgentChatPage
|
||||
key={agent_id}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
</ChatRuntime>
|
||||
);
|
||||
}
|
||||
|
||||
function AIWritingAgentRoutes({ base }: { base: string }) {
|
||||
function AIWritingAgentRoutes({
|
||||
base,
|
||||
compactAppearanceActions,
|
||||
}: {
|
||||
base: string;
|
||||
compactAppearanceActions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<RouteBaseProvider value={base}>
|
||||
<Routes>
|
||||
<Route index element={<AIWritingAgentDefaultRedirect />} />
|
||||
<Route path="agents/:agent_id/chats/:thread_id" element={<AIWritingAgentChatRoute />} />
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<AIWritingAgentChatRoute
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
@@ -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 正确解析
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
<AIWritingPanel
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
headerActionsBeforeCollapse={headerActionsBeforeCollapse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<GroupImperativeHandle>(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',
|
||||
)}
|
||||
>
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
<WritingFormPanel
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
headerActionsBeforeCollapse={compactAppearanceActions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 收起态:右上角浮动展开图标按钮(与展开态头部的收起按钮位置呼应) */}
|
||||
{!formExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormExpanded(true)}
|
||||
title="展开写作配置"
|
||||
className="absolute right-3 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<PanelRightOpen className="size-4" />
|
||||
</button>
|
||||
<div className="absolute right-3 top-2 z-10 flex items-center gap-1">
|
||||
{compactAppearanceActions}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormExpanded(true)}
|
||||
title="展开写作配置"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<PanelRightOpen className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -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 ? (
|
||||
<AIWritingCompactAppearanceActions />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex">
|
||||
@@ -361,7 +450,7 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
{showSidebar && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full shrink-0 overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)] transition-[width] duration-300 ease-in-out',
|
||||
'relative h-full shrink-0 overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)] transition-[width] duration-300 ease-in-out dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:text-[#c5cfe0] dark:shadow-none dark:backdrop-blur-md',
|
||||
sidebarCollapsed ? 'w-11' : 'w-[320px]',
|
||||
)}
|
||||
>
|
||||
@@ -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]"
|
||||
>
|
||||
<PanelLeftOpen className="size-4" />
|
||||
</button>
|
||||
<History className="size-4 text-slate-300" />
|
||||
<History className="size-4 text-slate-300 dark:text-[#6e88b0]" />
|
||||
</div>
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -388,8 +477,8 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
sidebarCollapsed ? 'pointer-events-none -translate-x-4 opacity-0' : 'translate-x-0 opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-slate-100 px-3 py-2">
|
||||
<div className="grid flex-1 grid-cols-2 rounded-lg bg-slate-100 p-1">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-slate-100 px-3 py-2 dark:border-[#2b8ffe]/20">
|
||||
<div className="grid flex-1 grid-cols-2 rounded-lg bg-slate-100 p-1 dark:bg-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -400,8 +489,8 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
|
||||
sidebarTab === 'agents'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800',
|
||||
? 'bg-white text-slate-900 shadow-sm dark:bg-[#122347]/90 dark:text-[#e8edf5] dark:shadow-none'
|
||||
: 'text-slate-500 hover:text-slate-800 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]',
|
||||
)}
|
||||
>
|
||||
课题研究
|
||||
@@ -416,8 +505,8 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
|
||||
sidebarTab === 'research'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800',
|
||||
? 'bg-white text-slate-900 shadow-sm dark:bg-[#122347]/90 dark:text-[#e8edf5] dark:shadow-none'
|
||||
: 'text-slate-500 hover:text-slate-800 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]',
|
||||
)}
|
||||
>
|
||||
ai写作
|
||||
@@ -428,7 +517,7 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
title="收起侧栏"
|
||||
aria-label="收起侧栏"
|
||||
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700"
|
||||
className="rounded-md p-1 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]"
|
||||
>
|
||||
<PanelLeftClose className="size-4" />
|
||||
</button>
|
||||
@@ -459,7 +548,13 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<div className={cn('absolute inset-0', (overlayActive || agentsActive) && 'hidden')}>
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
<AIWritingLayout
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
</div>
|
||||
{overlayActive && !agentsActive && (
|
||||
<div className="absolute inset-0 flex flex-col bg-background">
|
||||
@@ -499,8 +594,11 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
</div>
|
||||
)}
|
||||
{agentsActive && (
|
||||
<div className="absolute inset-0 bg-background">
|
||||
<AIWritingAgentRoutes base={routeBase} />
|
||||
<div className="workspace-chat-bg absolute inset-0">
|
||||
<AIWritingAgentRoutes
|
||||
base={routeBase}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -521,11 +619,13 @@ export default function AIWritingPage({
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
compactChrome = false,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
compactChrome?: boolean;
|
||||
} = {}) {
|
||||
return (
|
||||
<CanvasThreadProvider>
|
||||
@@ -544,6 +644,7 @@ export default function AIWritingPage({
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
compactChrome={compactChrome}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -50,13 +50,72 @@ import {
|
||||
type TemplateRenderResult,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { env } from "@/env";
|
||||
import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose";
|
||||
import {
|
||||
readTaskContext,
|
||||
taskThreadMetadata,
|
||||
type TaskContext,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { buildTaskOpeningText, readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
import {
|
||||
buildTaskOpeningText,
|
||||
readActionDetail,
|
||||
readCopTaskDetail,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
import type { RwfxSqGateStartContext } from "@/strategy-components/components/task-components/rwfx-sq-gate";
|
||||
|
||||
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
|
||||
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
|
||||
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
|
||||
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
|
||||
|
||||
function stringifyForHiddenContext(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
const text = JSON.stringify(value, null, 2);
|
||||
return typeof text === "string" ? text : "";
|
||||
} catch {
|
||||
return String(value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
function truncateMiddle(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
const marker = "\n\n...[内容过长,已截断中间部分]...\n\n";
|
||||
const tailChars = Math.min(RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS, Math.floor(maxChars / 4));
|
||||
const headChars = Math.max(0, maxChars - marker.length - tailChars);
|
||||
return `${text.slice(0, headChars)}${marker}${text.slice(-tailChars)}`;
|
||||
}
|
||||
|
||||
function compactSqReportForContext(report: unknown): unknown {
|
||||
if (report == null) return null;
|
||||
const serialized = stringifyForHiddenContext(report);
|
||||
if (serialized.length <= RWFX_SQ_REPORT_CONTEXT_MAX_CHARS) return report;
|
||||
return {
|
||||
truncated: true,
|
||||
originalChars: serialized.length,
|
||||
maxChars: RWFX_SQ_REPORT_CONTEXT_MAX_CHARS,
|
||||
content: truncateMiddle(serialized, RWFX_SQ_REPORT_CONTEXT_MAX_CHARS),
|
||||
};
|
||||
}
|
||||
|
||||
function rwfxSqReportRunContext(
|
||||
gateContext?: RwfxSqGateStartContext,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!gateContext?.sqReportStatus) return undefined;
|
||||
return {
|
||||
task_external_context: {
|
||||
kind: "rwfx_sq_report",
|
||||
status: gateContext.sqReportStatus,
|
||||
title: "三情分析(3q)研判情报报告",
|
||||
usage:
|
||||
gateContext.sqReportStatus === "complete"
|
||||
? "这是任务分析(rwfx)问答的隐藏业务上下文。回答时应参考这些已完成的 3q 结果;如 data.truncated=true,说明内容已截断,只能基于可见片段分析。不要向用户说明页面注入了隐藏上下文,除非用户明确询问。"
|
||||
: "3q 研判情报报告当前未提供有效结果。回答时不要编造 3q 结果,可仅基于任务信息继续分析。",
|
||||
data: compactSqReportForContext(gateContext.sqReport),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function AgentChatPage({
|
||||
embeddedAgentId,
|
||||
@@ -65,6 +124,7 @@ export default function AgentChatPage({
|
||||
taskBind,
|
||||
autoStartWhenPurposeEmpty,
|
||||
showAgentList,
|
||||
compactAppearanceActions,
|
||||
}: {
|
||||
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
|
||||
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
|
||||
@@ -85,6 +145,8 @@ export default function AgentChatPage({
|
||||
/** 是否在最左侧展示「智能体列表」栏(顶部下拉切换 我的/广场,点击进入该智能体对话)。
|
||||
* 仅独立的智能体问答整页开启;任务工作区 / 模块页 / 嵌入态不传(各有自己的左栏)。 */
|
||||
showAgentList?: boolean;
|
||||
/** 简洁模式下,挂到右侧智能体信息面板收起按钮左侧的外观控件。 */
|
||||
compactAppearanceActions?: ReactNode;
|
||||
} = {}) {
|
||||
const routeBase = useRouteBase();
|
||||
const { t } = useI18n();
|
||||
@@ -101,7 +163,8 @@ export default function AgentChatPage({
|
||||
|
||||
// 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。
|
||||
const taskCtxOn = !!(taskGate || taskBind);
|
||||
const taskContext = useMemo(() => {
|
||||
const isXdfxTaskBind = !!chatBasePath?.includes("/plan-task-action/");
|
||||
const taskContext = useMemo<TaskContext | null>(() => {
|
||||
if (!taskCtxOn) return null;
|
||||
const stored = readTaskContext();
|
||||
if (stored && stored.agentId === agent_id) return stored;
|
||||
@@ -111,15 +174,28 @@ export default function AgentChatPage({
|
||||
const detail = readCopTaskDetail();
|
||||
const fallbackTaskId = detail?.id == null ? "" : String(detail.id).trim();
|
||||
if (detail && fallbackTaskId && agent_id) {
|
||||
const actionDetail = isXdfxTaskBind ? readActionDetail() : null;
|
||||
const actionId = actionDetail?.id ? String(actionDetail.id).trim() : "";
|
||||
const actionTaskId = actionDetail?.taskId ? String(actionDetail.taskId).trim() : "";
|
||||
const hasMatchingAction =
|
||||
!!actionId && !!actionTaskId && actionTaskId === fallbackTaskId;
|
||||
const actionInfo = hasMatchingAction
|
||||
? {
|
||||
actionId,
|
||||
actionName: actionDetail?.actionName,
|
||||
narration: actionDetail?.narration,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
taskId: fallbackTaskId,
|
||||
agentId: agent_id,
|
||||
openingText: buildTaskOpeningText(detail, fallbackTaskId),
|
||||
openingText: buildTaskOpeningText(detail, fallbackTaskId, actionInfo),
|
||||
...(hasMatchingAction ? { actionId, partitionByAction: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return stored;
|
||||
}, [agent_id, taskBind, taskCtxOn, taskGate]);
|
||||
}, [agent_id, isXdfxTaskBind, taskBind, taskCtxOn, taskGate]);
|
||||
const taskId =
|
||||
taskCtxOn && taskContext && taskContext.agentId === agent_id
|
||||
? taskContext.taskId
|
||||
@@ -263,7 +339,7 @@ export default function AgentChatPage({
|
||||
}, [debugTemplateId, thread.messages]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
|
||||
setHasStartedConversation(true);
|
||||
// Remember this agent so the new-chat landing screen pre-selects it next
|
||||
// time (covers both direct agent chats and the ChatPage handoff replay).
|
||||
@@ -271,7 +347,7 @@ export default function AgentChatPage({
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message, { agent_id });
|
||||
void sendMessage(threadId, message, { ...(extraContext ?? {}), agent_id });
|
||||
},
|
||||
[sendMessage, threadId, agent_id],
|
||||
);
|
||||
@@ -294,9 +370,9 @@ export default function AgentChatPage({
|
||||
|
||||
// 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则
|
||||
// 仅切到对话态(隐藏面板),让用户手动输入。
|
||||
const handleGateStart = useCallback(() => {
|
||||
const handleGateStart = useCallback((gateContext?: RwfxSqGateStartContext) => {
|
||||
const text = taskContext?.openingText?.trim();
|
||||
if (text) handleSubmit({ text, files: [] });
|
||||
if (text) handleSubmit({ text, files: [] }, rwfxSqReportRunContext(gateContext));
|
||||
else setHasStartedConversation(true);
|
||||
}, [handleSubmit, taskContext]);
|
||||
|
||||
@@ -589,6 +665,7 @@ export default function AgentChatPage({
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
defaultCollapsed={taskCtxOn}
|
||||
showRecentChats={!taskCtxOn}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { QuestionNavigator } from "@/components/workspace/question-navigator";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
@@ -508,6 +509,7 @@ export default function ChatPage() {
|
||||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||||
{!compactChrome && (
|
||||
<>
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
<div
|
||||
|
||||
@@ -15,6 +15,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
@@ -40,7 +45,11 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function ChatsPage() {
|
||||
export default function ChatsPage({
|
||||
compactChrome = false,
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
}) {
|
||||
const pathOfThread = usePathOfThread();
|
||||
const { t } = useI18n();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
@@ -171,7 +180,17 @@ export default function ChatsPage() {
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader></WorkspaceHeader>
|
||||
<WorkspaceHeader
|
||||
rightActions={
|
||||
compactChrome ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full flex-col">
|
||||
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
|
||||
|
||||
@@ -26,18 +26,19 @@ import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
|
||||
import { getAccessToken } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import {
|
||||
getLastAgentId,
|
||||
isThreadRagModeLocked,
|
||||
saveLastAgentId,
|
||||
useLocalSettings,
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
@@ -50,6 +51,22 @@ import { resolveIframeAssistantId } from "./iframe-chat-agent";
|
||||
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons";
|
||||
|
||||
type BrainyChatStatus = "started" | "finished";
|
||||
type ResolvedIframeModeButton =
|
||||
| {
|
||||
key: "agent";
|
||||
label: string;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
| {
|
||||
key: "writing" | "memory" | "references" | "contextCompression" | "pro";
|
||||
label: string;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
icon: typeof FileTextIcon;
|
||||
onClick: () => void;
|
||||
activeClassName: string;
|
||||
};
|
||||
type BrainyAuthTokenMessage = {
|
||||
source: "brainy-iframe";
|
||||
type: "brainy:auth-token";
|
||||
@@ -66,6 +83,20 @@ type BrainyChatInterruptMessage = {
|
||||
};
|
||||
requestId?: string;
|
||||
};
|
||||
type BrainyUrlContextMessage = {
|
||||
source?: string;
|
||||
type?: string;
|
||||
payload?: {
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
type PendingIframeSubmit = {
|
||||
message: PromptInputMessage;
|
||||
externalContext?: Record<string, unknown>;
|
||||
options?: { additionalKwargs?: Record<string, unknown> };
|
||||
targetSelectedAgentId: string | null;
|
||||
targetForceDefaultAssistant: boolean;
|
||||
};
|
||||
|
||||
function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
|
||||
window.parent?.postMessage(
|
||||
@@ -104,9 +135,22 @@ export default function IframeChatPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [manualSelectedAgentId, setManualSelectedAgentId] = useState<string | null>(null);
|
||||
const [forceDefaultAssistant, setForceDefaultAssistant] = useState(true);
|
||||
const [pendingIframeSubmit, setPendingIframeSubmit] =
|
||||
useState<PendingIframeSubmit | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
const { data: pluginManagementSettings } = usePluginManagementSettings();
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
const handleSubmitRef = useRef<
|
||||
| ((
|
||||
message: PromptInputMessage,
|
||||
externalContext?: Record<string, unknown>,
|
||||
options?: { additionalKwargs?: Record<string, unknown> },
|
||||
) => void)
|
||||
| null
|
||||
>(null);
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
@@ -139,23 +183,37 @@ export default function IframeChatPage() {
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setSelectedAgentId(getLastAgentId());
|
||||
setSelectedAgentId(null);
|
||||
setManualSelectedAgentId(null);
|
||||
setForceDefaultAssistant(true);
|
||||
setPendingIframeSubmit(null);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectorLoading || !selectedAgentId) {
|
||||
if (selectorLoading) {
|
||||
return;
|
||||
}
|
||||
if (!selectorAgents.some((agent) => agent.id === selectedAgentId)) {
|
||||
if (
|
||||
selectedAgentId &&
|
||||
!selectorAgents.some((agent) => agent.id === selectedAgentId)
|
||||
) {
|
||||
setSelectedAgentId(null);
|
||||
setForceDefaultAssistant(true);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, selectorLoading]);
|
||||
if (
|
||||
manualSelectedAgentId &&
|
||||
!selectorAgents.some((agent) => agent.id === manualSelectedAgentId)
|
||||
) {
|
||||
setManualSelectedAgentId(null);
|
||||
}
|
||||
}, [manualSelectedAgentId, selectedAgentId, selectorAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const activeAssistantId = resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId: null,
|
||||
preferDefaultAssistant: forceDefaultAssistant,
|
||||
});
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
@@ -209,14 +267,18 @@ export default function IframeChatPage() {
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedAgentId = resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId,
|
||||
});
|
||||
if (resolvedAgentId && resolvedAgentId !== selectedAgentId) {
|
||||
setSelectedAgentId(resolvedAgentId);
|
||||
}
|
||||
}, [selectedAgentId, threadMetadataAgentId]);
|
||||
if (!threadMetadataAgentId || isNewThread) return;
|
||||
if (manualSelectedAgentId) return;
|
||||
if (forceDefaultAssistant) return;
|
||||
if (selectedAgentId) return;
|
||||
setSelectedAgentId(threadMetadataAgentId);
|
||||
}, [
|
||||
forceDefaultAssistant,
|
||||
isNewThread,
|
||||
manualSelectedAgentId,
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId,
|
||||
]);
|
||||
|
||||
const writingArtifactPath = useMemo(() => {
|
||||
const isMarkdown = (path: string | null | undefined) =>
|
||||
@@ -277,10 +339,43 @@ export default function IframeChatPage() {
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||||
setManualSelectedAgentId(agentId);
|
||||
setSelectedAgentId(agentId);
|
||||
setForceDefaultAssistant(!agentId);
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingIframeSubmit) return;
|
||||
const currentAssistantId = resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId: null,
|
||||
preferDefaultAssistant: forceDefaultAssistant,
|
||||
});
|
||||
const targetAssistantId = resolveIframeAssistantId({
|
||||
selectedAgentId: pendingIframeSubmit.targetSelectedAgentId,
|
||||
threadMetadataAgentId: null,
|
||||
preferDefaultAssistant: pendingIframeSubmit.targetForceDefaultAssistant,
|
||||
});
|
||||
if (
|
||||
currentAssistantId !== targetAssistantId ||
|
||||
selectedAgentId !== pendingIframeSubmit.targetSelectedAgentId ||
|
||||
forceDefaultAssistant !== pendingIframeSubmit.targetForceDefaultAssistant
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleSubmitRef.current?.(
|
||||
pendingIframeSubmit.message,
|
||||
pendingIframeSubmit.externalContext,
|
||||
pendingIframeSubmit.options,
|
||||
);
|
||||
setPendingIframeSubmit(null);
|
||||
}, [forceDefaultAssistant, pendingIframeSubmit, selectedAgentId]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
@@ -341,9 +436,33 @@ export default function IframeChatPage() {
|
||||
[thread],
|
||||
);
|
||||
|
||||
const applyIncomingUrlSelection = useCallback(
|
||||
(pageUrl: string | null | undefined) => {
|
||||
const nextSelection = resolveIncomingIframeSelection({
|
||||
pageUrl,
|
||||
mappings: pluginManagementSettings?.domainAgentMappings ?? [],
|
||||
availableAgentIds: selectorLoading
|
||||
? undefined
|
||||
: selectorAgents.map((agent) => agent.id),
|
||||
manualSelectedAgentId,
|
||||
});
|
||||
setSelectedAgentId(nextSelection.selectedAgentId);
|
||||
setForceDefaultAssistant(nextSelection.forceDefaultAssistant);
|
||||
return nextSelection;
|
||||
},
|
||||
[
|
||||
manualSelectedAgentId,
|
||||
pluginManagementSettings?.domainAgentMappings,
|
||||
selectorAgents,
|
||||
selectorLoading,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (
|
||||
event: MessageEvent<BrainyChatSubmitMessage | BrainyChatInterruptMessage>,
|
||||
event: MessageEvent<
|
||||
BrainyChatSubmitMessage | BrainyChatInterruptMessage | BrainyUrlContextMessage
|
||||
>,
|
||||
) => {
|
||||
const data = event.data;
|
||||
if (data?.source !== "brainy-extension") return;
|
||||
@@ -351,12 +470,19 @@ export default function IframeChatPage() {
|
||||
void handleInterrupt(data as BrainyChatInterruptMessage);
|
||||
return;
|
||||
}
|
||||
if (data?.type === "brainy:context-url") {
|
||||
applyIncomingUrlSelection(
|
||||
(data as BrainyUrlContextMessage).payload?.url ?? null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (data?.type !== "brainy:chat-submit") return;
|
||||
const submitMessage = data as BrainyChatSubmitMessage;
|
||||
|
||||
const { submitText, files, iframeContext, additionalKwargs } =
|
||||
parseBrainyChatSubmitMessage(submitMessage);
|
||||
|
||||
if (!submitText && files.length === 0) return;
|
||||
|
||||
console.log("收到插件消息", {
|
||||
text: submitMessage.payload?.text,
|
||||
prompt: submitMessage.payload?.prompt,
|
||||
@@ -368,26 +494,30 @@ export default function IframeChatPage() {
|
||||
iframeContext,
|
||||
});
|
||||
|
||||
if (!submitText && files.length === 0) return;
|
||||
|
||||
pendingRequestIdRef.current = submitMessage.requestId ?? null;
|
||||
|
||||
handleSubmit(
|
||||
{ text: submitText, files },
|
||||
iframeContext,
|
||||
additionalKwargs ? { additionalKwargs } : undefined,
|
||||
);
|
||||
setPendingIframeSubmit({
|
||||
message: { text: submitText, files },
|
||||
externalContext: iframeContext,
|
||||
options: additionalKwargs ? { additionalKwargs } : undefined,
|
||||
targetSelectedAgentId: selectedAgentId,
|
||||
targetForceDefaultAssistant: forceDefaultAssistant,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [handleInterrupt, handleSubmit]);
|
||||
}, [
|
||||
applyIncomingUrlSelection,
|
||||
forceDefaultAssistant,
|
||||
handleInterrupt,
|
||||
selectedAgentId,
|
||||
]);
|
||||
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
const bottomBarHeightClass = "pb-24";
|
||||
const modeButtons = buildIframeModeButtons({
|
||||
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
|
||||
agentSelected: !!selectedAgentId,
|
||||
writingModeEnabled: !!settings.context.writing_mode,
|
||||
memoryInjectionEnabled: Boolean(
|
||||
@@ -397,10 +527,15 @@ export default function IframeChatPage() {
|
||||
ragModeLocked,
|
||||
contextCompressionEnabled,
|
||||
proModeEnabled,
|
||||
}).map((button) => {
|
||||
}).map((button): ResolvedIframeModeButton => {
|
||||
switch (button.key) {
|
||||
case "agent":
|
||||
return button;
|
||||
return {
|
||||
key: "agent",
|
||||
label: button.label,
|
||||
active: button.active,
|
||||
disabled: button.disabled,
|
||||
};
|
||||
case "writing":
|
||||
return {
|
||||
...button,
|
||||
@@ -437,7 +572,7 @@ export default function IframeChatPage() {
|
||||
activeClassName: "text-fuchsia-600 hover:text-fuchsia-700",
|
||||
};
|
||||
default:
|
||||
return button;
|
||||
throw new Error(`Unknown iframe mode button: ${String(button.key)}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -89,9 +89,9 @@ const emptyForm = (): FormState => ({
|
||||
});
|
||||
|
||||
const newParam = (): LightAppRouteParam => ({
|
||||
key: "",
|
||||
key: "access_token",
|
||||
paramType: "login",
|
||||
source: "accessToken",
|
||||
source: "access_token",
|
||||
customValue: "",
|
||||
themeValue: "red=red;blue=blue;dark-blue=dark-blue",
|
||||
});
|
||||
@@ -107,7 +107,7 @@ function formFromApp(app: LightApp): FormState {
|
||||
routeParams: app.routeParams.map((p) => ({
|
||||
key: p.key,
|
||||
paramType: p.paramType,
|
||||
source: p.source ?? "accessToken",
|
||||
source: p.source ?? "access_token",
|
||||
customValue: p.customValue ?? "",
|
||||
themeValue: p.themeValue ?? "",
|
||||
})),
|
||||
@@ -118,7 +118,7 @@ function getParamSourceLabel(param: LightAppRouteParam): string {
|
||||
if (param.paramType === "theme") {
|
||||
return `主题参数: ${param.themeValue || ""}`;
|
||||
}
|
||||
const source = param.source || "accessToken";
|
||||
const source = param.source || "access_token";
|
||||
const option = LOGIN_SOURCE_OPTIONS.find((o) => o.value === source);
|
||||
if (source === "other") {
|
||||
return `登录参数: ${param.customValue || ""}`;
|
||||
@@ -977,7 +977,7 @@ export default function LightAppManagementPage() {
|
||||
onChange={(v) =>
|
||||
patchParam(idx, {
|
||||
paramType: v as "login" | "theme",
|
||||
source: v === "login" ? (p.source || "accessToken") : undefined,
|
||||
source: v === "login" ? (p.source || "access_token") : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -988,7 +988,7 @@ export default function LightAppManagementPage() {
|
||||
<Select
|
||||
className="w-full"
|
||||
size="small"
|
||||
value={p.source || "accessToken"}
|
||||
value={p.source || "access_token"}
|
||||
options={LOGIN_SOURCE_OPTIONS}
|
||||
popupProps={DIALOG_SELECT_POPUP}
|
||||
onChange={(v) => patchParam(idx, { source: v as string })}
|
||||
@@ -1112,7 +1112,7 @@ function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
|
||||
.map((p) => {
|
||||
const base: LightAppRouteParam = { key: p.key.trim(), paramType: p.paramType };
|
||||
if (p.paramType === "login") {
|
||||
base.source = p.source || "accessToken";
|
||||
base.source = p.source || "access_token";
|
||||
if (base.source === "other") {
|
||||
base.customValue = (p.customValue ?? "").trim();
|
||||
}
|
||||
|
||||
@@ -35,6 +35,35 @@ import { fetchYLogConfig } from '@/core/log/y-log-config';
|
||||
import { persistYLogToken } from '@/core/log/y-log-token';
|
||||
import { persistLoginCredentials } from '@/core/auth/login-credentials';
|
||||
import { OptType, writeYLog } from '@/core/log/y-log';
|
||||
import { authStorageGet } from '@/core/auth/scoped-storage';
|
||||
|
||||
/** 令牌脱敏:日志里只露头尾,避免完整令牌泄漏。 */
|
||||
function maskLoginToken(value: string): string {
|
||||
if (!value) return '';
|
||||
if (value.length <= 10) return `${value.slice(0, 2)}***(${value.length})`;
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}(${value.length})`;
|
||||
}
|
||||
|
||||
/** 读取已落地的 userInfo(脱敏 access token 后)供登录后排查。 */
|
||||
function summarizeStoredUserInfo(): Record<string, unknown> | null {
|
||||
try {
|
||||
const raw = authStorageGet('userInfo');
|
||||
if (!raw) return null;
|
||||
const info = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
keys: Object.keys(info),
|
||||
yUserId: info.yUserId,
|
||||
userId: info.userId,
|
||||
userName: info.userName,
|
||||
gateDepartName: info.gateDepartName,
|
||||
typeName: info.typeName,
|
||||
access_token: maskLoginToken(String(info.access_token ?? '')),
|
||||
accessToken: maskLoginToken(String(info.accessToken ?? '')),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
const IFRAME_LOGIN_SOURCE = 'iframe';
|
||||
@@ -75,7 +104,7 @@ export default function LoginPage() {
|
||||
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
||||
const accessToken = searchParams.get('access_token')?.trim() ?? '';
|
||||
const access_token = searchParams.get('access_token')?.trim() ?? '';
|
||||
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
|
||||
const gatePassword = searchParams.get('password') ?? '';
|
||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||
@@ -171,19 +200,68 @@ export default function LoginPage() {
|
||||
async function runLogin() {
|
||||
setStatus('loading');
|
||||
setErrorMessage('');
|
||||
|
||||
// ── 调试:登录路由参数全打印 ──────────────────────────────────────────────
|
||||
// token / yUserId / 用户名 / ticket 任一方式都打印(令牌脱敏),方便排查
|
||||
// “userInfo 取不到值 / 传值不对” 一类问题。`embed=0`/正常态也照打。
|
||||
const loginMethod = authToken
|
||||
? 'token(authToken)'
|
||||
: yUserId
|
||||
? 'yUserId'
|
||||
: onlineTicket
|
||||
? 'onlineTicket'
|
||||
: toolToken
|
||||
? 'toolsToken'
|
||||
: 'username';
|
||||
console.info('[login] route params', {
|
||||
method: loginMethod,
|
||||
pathUsername: resolvedUsername,
|
||||
yUserId,
|
||||
access_token: maskLoginToken(access_token),
|
||||
toolToken: maskLoginToken(toolToken),
|
||||
authToken: maskLoginToken(authToken),
|
||||
gatePassword: gatePassword ? maskLoginToken(gatePassword) : '',
|
||||
onlineTicket: onlineTicket ? maskLoginToken(onlineTicket) : '',
|
||||
taskId,
|
||||
goPath,
|
||||
redirect: redirectTo,
|
||||
isEmbed,
|
||||
rawSearch: searchParams.toString(),
|
||||
});
|
||||
|
||||
try {
|
||||
let auth;
|
||||
let loginUserName = '';
|
||||
|
||||
// 外部用户信息(yUserId → consumer 用户详情)与登录鉴权方式**解耦**:只要 URL
|
||||
// 带 yUserId 就拉一次 userDetailByYuserId,把 yUserId/userName/gateDepartName/
|
||||
// typeName/accessToken 落进 userInfo。之前 token(authToken) 登录会短路掉这一步,
|
||||
// 导致 token 登录后 userInfo 残缺(只剩 userName)、iframe/轻应用拿不到这些值。
|
||||
if (yUserId) {
|
||||
if (authToken) {
|
||||
// token 登录下 consumer 失败不应阻断登录(仅影响 userInfo 的富信息)。
|
||||
try {
|
||||
await loginByYUserId(yUserId, toolToken || null, access_token || null);
|
||||
} catch (error) {
|
||||
console.warn('[login] loginByYUserId failed under token login (non-fatal)', error);
|
||||
}
|
||||
} else {
|
||||
await loginByYUserId(yUserId, toolToken || null, access_token || null);
|
||||
}
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
|
||||
auth = await loginByToken(authToken);
|
||||
// 让 token 登录也写入正确用户名(后端 getTokenInfo 解析出的原始登录名)。
|
||||
// 若上面 yUserId 已从 consumer 落了 userName,则 setStoredUserName 不会覆盖它。
|
||||
loginUserName = auth.username?.trim() ?? '';
|
||||
} else {
|
||||
let deerflowUsername = resolvedUsername;
|
||||
if (onlineTicket) {
|
||||
// yUserId 优先(已在上面落好 userInfo + tools_token);其余方式仅在无 yUserId 时启用。
|
||||
if (!yUserId && onlineTicket) {
|
||||
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
|
||||
} else if (yUserId) {
|
||||
await loginByYUserId(yUserId, toolToken || null);
|
||||
} else if (toolToken) {
|
||||
} else if (!yUserId && toolToken) {
|
||||
persistToolsToken(toolToken);
|
||||
}
|
||||
|
||||
@@ -212,9 +290,22 @@ export default function LoginPage() {
|
||||
}
|
||||
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
|
||||
setStoredUserName(loginUserName);
|
||||
// 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
|
||||
// XK/PG 中任务 iframe 据此取 accessToken
|
||||
persistAccessToken(accessToken);
|
||||
// 非 yUserId 登录仍可直接持久化 access_token;yUserId 登录已在 loginByYUserId 内按
|
||||
// “接口 accessToken 优先,URL access_token 仅兜底”的规则处理。
|
||||
if (!yUserId) {
|
||||
persistAccessToken(access_token);
|
||||
}
|
||||
|
||||
// ── 调试:登录落地结果(auth + 实际写入的 userInfo,access token 已脱敏) ──
|
||||
console.info('[login] stored result', {
|
||||
method: loginMethod,
|
||||
user_id: auth.user_id,
|
||||
email: auth.email,
|
||||
authUsername: auth.username,
|
||||
loginUserName,
|
||||
userInfo: summarizeStoredUserInfo(),
|
||||
});
|
||||
|
||||
// 嵌入模式不写 sidebarEnabled(localStorage 的「简洁/全量」偏好会跨标签污染正常页);
|
||||
// 去 chrome 由按标签页的嵌入会话(isEmbedActive)驱动,见 PageLayout。
|
||||
if (!isEmbed) persistSidebarEnabled(sidebarEnabled);
|
||||
@@ -273,7 +364,7 @@ export default function LoginPage() {
|
||||
sidebarEnabled,
|
||||
yUserId,
|
||||
toolToken,
|
||||
accessToken,
|
||||
access_token,
|
||||
onlineTicket,
|
||||
authToken,
|
||||
gatePassword,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
useModelConcurrencySettings,
|
||||
useUpdateModelConcurrencySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export default function ModelConcurrencySettingsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { data, isLoading, error } = useModelConcurrencySettings();
|
||||
const { models } = useModels();
|
||||
const update = useUpdateModelConcurrencySettings();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [defaultLimit, setDefaultLimit] = useState(10);
|
||||
const [modelLimits, setModelLimits] = useState<Record<string, string>>({});
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setEnabled(data.enabled);
|
||||
setDefaultLimit(data.default_limit);
|
||||
setModelLimits(
|
||||
Object.fromEntries(
|
||||
Object.entries(data.model_limits ?? {}).map(([name, limit]) => [
|
||||
name,
|
||||
String(limit),
|
||||
]),
|
||||
),
|
||||
);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
const modelNames = useMemo(() => models.map((model) => model.name), [models]);
|
||||
|
||||
const handleModelLimitChange = (modelName: string, value: string) => {
|
||||
setModelLimits((prev) => ({ ...prev, [modelName]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const nextDefault = Math.max(1, Math.min(Math.round(defaultLimit) || 10, 10000));
|
||||
const cleaned: Record<string, number> = {};
|
||||
for (const [name, rawValue] of Object.entries(modelLimits)) {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) continue;
|
||||
const parsed = Math.round(Number(trimmed));
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
toast.error(`模型 ${name} 的并发上限必须是大于 0 的整数`);
|
||||
return;
|
||||
}
|
||||
cleaned[name] = Math.min(parsed, 10000);
|
||||
}
|
||||
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
enabled,
|
||||
default_limit: nextDefault,
|
||||
model_limits: cleaned,
|
||||
});
|
||||
setDefaultLimit(nextDefault);
|
||||
setModelLimits(
|
||||
Object.fromEntries(
|
||||
Object.entries(cleaned).map(([name, limit]) => [name, String(limit)]),
|
||||
),
|
||||
);
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? "flex w-full flex-col gap-6"
|
||||
: "mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 overflow-y-auto px-6 py-10"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
|
||||
模型并发限制
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
按模型限制同时进行中的问答数。达到上限后,新请求会收到“当前使用人数较多,请稍等一下再试”的提示。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">开启限制</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
关闭后,不再按模型并发数拦截问答请求。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">默认上限</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
未单独配置的模型,使用这个并发上限。
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={defaultLimit}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setDefaultLimit(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">按模型单独设置</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
留空表示使用默认上限。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{modelNames.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-6 text-sm">
|
||||
当前没有可配置的模型。
|
||||
</div>
|
||||
) : (
|
||||
modelNames.map((modelName) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="flex items-center justify-between gap-4 rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{modelName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
留空时使用默认上限 {defaultLimit || 10}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={modelLimits[modelName] ?? ""}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => handleModelLimitChange(modelName, e.target.value)}
|
||||
className="w-32"
|
||||
placeholder={String(defaultLimit || 10)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import {
|
||||
type DomainAgentMapping,
|
||||
normalizeDomainInput,
|
||||
} from "@/core/plugin-management/domain-agent";
|
||||
import type { PluginManagementSettings } from "@/core/system-settings/api";
|
||||
import {
|
||||
usePluginManagementSettings,
|
||||
useUpdatePluginManagementSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
function makeRow(): DomainAgentMapping {
|
||||
return {
|
||||
id:
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
domain: "",
|
||||
agentId: "",
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRows(
|
||||
rows: DomainAgentMapping[],
|
||||
): PluginManagementSettings["domainAgentMappings"] {
|
||||
const seen = new Set<string>();
|
||||
const next: PluginManagementSettings["domainAgentMappings"] = [];
|
||||
for (const row of rows) {
|
||||
const domain = normalizeDomainInput(row.domain);
|
||||
const agentId = row.agentId.trim();
|
||||
if (!domain || !agentId) continue;
|
||||
if (seen.has(domain)) continue;
|
||||
seen.add(domain);
|
||||
next.push({
|
||||
id: row.id || makeRow().id,
|
||||
domain,
|
||||
agentId,
|
||||
enabled: row.enabled,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function PluginManagementPage() {
|
||||
const { data, isLoading, error } = usePluginManagementSettings();
|
||||
const update = useUpdatePluginManagementSettings();
|
||||
const { agents, isLoading: agentsLoading } = useAgentSelector();
|
||||
const [rows, setRows] = useState<DomainAgentMapping[]>([]);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setRows(
|
||||
data.domainAgentMappings.length > 0
|
||||
? data.domainAgentMappings
|
||||
: [makeRow()],
|
||||
);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
const agentOptions = useMemo(
|
||||
() =>
|
||||
[...agents]
|
||||
.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id, "zh-CN"))
|
||||
.map((agent) => ({
|
||||
id: agent.id,
|
||||
label: agent.name || agent.id,
|
||||
})),
|
||||
[agents],
|
||||
);
|
||||
|
||||
const updateRow = (id: string, patch: Partial<DomainAgentMapping>) => {
|
||||
setRows((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeRow = (id: string) => {
|
||||
setRows((current) => {
|
||||
const next = current.filter((row) => row.id !== id);
|
||||
return next.length > 0 ? next : [makeRow()];
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const domainAgentMappings = normalizeRows(rows);
|
||||
try {
|
||||
const saved = await update.mutateAsync({ domainAgentMappings });
|
||||
setRows(
|
||||
saved.domainAgentMappings.length > 0
|
||||
? saved.domainAgentMappings
|
||||
: [makeRow()],
|
||||
);
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-10">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">插件管理</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理插件传入页面地址的域名与默认智能体映射。命中后,插件ZNCM模式会按配置默认选择对应智能体。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">域名和智能体配置</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
输入域名或完整 URL,保存时会自动规范化为 host。只按域名精确匹配。
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRows((current) => [...current, makeRow()])}
|
||||
>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
新增配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_120px_72px] gap-3 text-sm font-medium">
|
||||
<div>域名</div>
|
||||
<div>智能体</div>
|
||||
<div>启用</div>
|
||||
<div>操作</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_120px_72px] gap-3"
|
||||
>
|
||||
<Input
|
||||
value={row.domain}
|
||||
onChange={(e) => updateRow(row.id, { domain: e.target.value })}
|
||||
placeholder="如:https://example.com/path 或 example.com"
|
||||
/>
|
||||
<select
|
||||
value={row.agentId}
|
||||
onChange={(e) => updateRow(row.id, { agentId: e.target.value })}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={agentsLoading}
|
||||
>
|
||||
<option value="">请选择智能体</option>
|
||||
{agentOptions.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
checked={row.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRow(row.id, { enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-9 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label="删除配置"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,11 @@ import {
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
@@ -144,7 +149,11 @@ const SCHEDULED_TASK_TEXTAREA_CLASS =
|
||||
const DESCRIPTION_TEXTAREA_ROWS = 4;
|
||||
const PROMPT_TEXTAREA_ROWS = 10;
|
||||
|
||||
export default function ScheduledTasksPage() {
|
||||
export default function ScheduledTasksPage({
|
||||
compactChrome = false,
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useTheme();
|
||||
const darkToastOpts =
|
||||
@@ -490,7 +499,17 @@ export default function ScheduledTasksPage() {
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceHeader
|
||||
rightActions={
|
||||
compactChrome ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<WorkspaceBody>
|
||||
<div className="grid size-full min-h-0 grid-cols-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 p-4 xl:grid-cols-[420px_minmax(0,1fr)] xl:grid-rows-1">
|
||||
<Card className="flex min-h-0 min-w-0 flex-col">
|
||||
|
||||
@@ -51,6 +51,7 @@ import MemoryPage from "./MemoryPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
@@ -346,6 +347,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/model-concurrency"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<ModelConcurrencySettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function TaskButtonsPage() {
|
||||
);
|
||||
|
||||
const addLoginParam = (id: string) =>
|
||||
updateLoginParams(id, (ps) => [...ps, { key: "", source: "accessToken", customValue: "" }]);
|
||||
updateLoginParams(id, (ps) => [...ps, { key: "access_token", source: "access_token", customValue: "" }]);
|
||||
|
||||
const patchLoginParam = (id: string, idx: number, patch: Partial<TaskButtonLoginParam>) =>
|
||||
updateLoginParams(id, (ps) => ps.map((p, i) => (i === idx ? { ...p, ...patch } : p)));
|
||||
|
||||
@@ -5,7 +5,13 @@ import { getStoredAuth } from '@/core/auth';
|
||||
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from '@/components/workspace/appearance-controls';
|
||||
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
|
||||
import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
|
||||
import { CommandPalette } from '@/components/workspace/command-palette';
|
||||
import { Toaster } from 'sonner';
|
||||
import { SubtasksProvider } from '@/core/tasks/context';
|
||||
@@ -31,6 +37,7 @@ import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
|
||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
@@ -41,6 +48,7 @@ import AdminMemoryPage from './AdminMemoryPage';
|
||||
import AdminUserHubPage from './AdminUserHubPage';
|
||||
import TagsPage from './TagsPage';
|
||||
import PositionsPage from './PositionsPage';
|
||||
import PluginManagementPage from './PluginManagementPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
@@ -71,6 +79,38 @@ function isInIframe(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
return (
|
||||
pathname === '/page/workspace/agents' ||
|
||||
pathname === '/page/workspace/skills' ||
|
||||
pathname === '/page/workspace/memory' ||
|
||||
pathname === '/page/workspace/knowledge' ||
|
||||
pathname.startsWith('/page/workspace/knowledge/')
|
||||
);
|
||||
}
|
||||
|
||||
function CompactWorkspaceTopActions() {
|
||||
return (
|
||||
<div className="z-[60] flex h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 bg-card/90 px-4 shadow-sm backdrop-blur-md">
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
<div className="bg-border/70 mx-1 h-6 w-px shrink-0" aria-hidden />
|
||||
<UserIdentityBadge />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactAppearanceActions() {
|
||||
return (
|
||||
<>
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceLayout({
|
||||
children,
|
||||
pageSidebarCollapsed,
|
||||
@@ -97,7 +137,10 @@ export function WorkspaceLayout({
|
||||
}) {
|
||||
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
||||
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
||||
const isGeneralQa = useLocation().pathname.startsWith("/page/workspace/chats");
|
||||
const pathname = useLocation().pathname;
|
||||
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
|
||||
const showCompactTopActions =
|
||||
headerHidden && shouldShowCompactTopActions(pathname);
|
||||
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? '0rem'
|
||||
@@ -135,7 +178,14 @@ export function WorkspaceLayout({
|
||||
<SidebarInset
|
||||
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
|
||||
>
|
||||
{children}
|
||||
{showCompactTopActions ? (
|
||||
<>
|
||||
<CompactWorkspaceTopActions />
|
||||
<div className="min-h-0 flex-1">{children}</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
@@ -221,7 +271,7 @@ export default function WorkspaceRoutes({
|
||||
path="chats"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatsPage />
|
||||
<ChatsPage compactChrome={headerHidden} />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -304,7 +354,12 @@ export default function WorkspaceRoutes({
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<AgentChatPage showAgentList />
|
||||
<AgentChatPage
|
||||
showAgentList
|
||||
compactAppearanceActions={
|
||||
headerHidden ? <CompactAppearanceActions /> : undefined
|
||||
}
|
||||
/>
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -337,7 +392,10 @@ export default function WorkspaceRoutes({
|
||||
path="ai-writing/*"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AIWritingPage hideAdvancedFields />
|
||||
<AIWritingPage
|
||||
hideAdvancedFields
|
||||
compactChrome={headerHidden}
|
||||
/>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -370,7 +428,7 @@ export default function WorkspaceRoutes({
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
<RoundtablePlanningPage compactChrome={headerHidden} />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -432,7 +490,7 @@ export default function WorkspaceRoutes({
|
||||
path="scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTasksPage />
|
||||
<ScheduledTasksPage compactChrome={headerHidden} />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -514,6 +572,18 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/plugins"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PluginManagementPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/positions"
|
||||
element={
|
||||
@@ -562,6 +632,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/model-concurrency"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ModelConcurrencySettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/publish-squares"
|
||||
element={
|
||||
|
||||
@@ -33,3 +33,14 @@ test("falls back to lead-agent mode when no agent is selected or bound", () => {
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("can force lead-agent mode even when the thread metadata is bound to another agent", () => {
|
||||
assert.equal(
|
||||
resolveIframeAssistantId({
|
||||
selectedAgentId: null,
|
||||
threadMetadataAgentId: "roundtable-structure",
|
||||
preferDefaultAssistant: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
type ResolveIframeAssistantIdInput = {
|
||||
selectedAgentId: string | null;
|
||||
threadMetadataAgentId: string | null;
|
||||
preferDefaultAssistant?: boolean;
|
||||
};
|
||||
|
||||
function normalizeAgentId(agentId: string | null | undefined): string | undefined {
|
||||
@@ -11,7 +12,11 @@ function normalizeAgentId(agentId: string | null | undefined): string | undefine
|
||||
export function resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId,
|
||||
preferDefaultAssistant = false,
|
||||
}: ResolveIframeAssistantIdInput): string | undefined {
|
||||
if (preferDefaultAssistant) {
|
||||
return normalizeAgentId(selectedAgentId);
|
||||
}
|
||||
return (
|
||||
normalizeAgentId(selectedAgentId) ??
|
||||
normalizeAgentId(threadMetadataAgentId)
|
||||
|
||||
@@ -61,6 +61,10 @@ import {
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -282,7 +286,11 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
|
||||
}
|
||||
|
||||
|
||||
export default function RoundtablePlanningPage() {
|
||||
export default function RoundtablePlanningPage({
|
||||
compactChrome = false,
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||
const { watchJob } = useRoundtableJobNotifier();
|
||||
@@ -2075,6 +2083,12 @@ export default function RoundtablePlanningPage() {
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
{compactChrome && (
|
||||
<>
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2715,7 +2729,8 @@ function LeftSidebar({
|
||||
ref={dzRef}
|
||||
id="left-sidebar"
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out",
|
||||
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
|
||||
isStep2 ? "overflow-y-auto" : "overflow-hidden",
|
||||
isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]",
|
||||
)}
|
||||
>
|
||||
@@ -2897,6 +2912,7 @@ function RoundtableEntryRail({
|
||||
businessCode?: string | null;
|
||||
}) {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
|
||||
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
|
||||
const { drafts: records, refresh } = useRoundtableDrafts(taskId);
|
||||
// 分析记录「按业务链筛选」—— 选项从已加载草稿的 chainTitle 去重得到,
|
||||
@@ -2947,7 +2963,7 @@ function RoundtableEntryRail({
|
||||
let cancelled = false;
|
||||
void listChains("public")
|
||||
.then((all) => {
|
||||
if (!cancelled) setChains(all.slice(0, 3));
|
||||
if (!cancelled) setChains(all);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 业务链非关键,静默忽略 */
|
||||
@@ -2971,7 +2987,7 @@ function RoundtableEntryRail({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
|
||||
{deeplink ? (
|
||||
isXdfx ? (
|
||||
@@ -3014,33 +3030,60 @@ function RoundtableEntryRail({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* 业务链(公共,前 3 个) */
|
||||
/* 业务链(公共列表,内部滚动) */
|
||||
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] transition-shadow duration-300 hover:shadow-[0_4px_24px_rgba(45,37,34,0.04)]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
|
||||
<Layers className="w-3 h-3" strokeWidth={2.5} />
|
||||
</span>
|
||||
<h2 className="text-sm font-semibold tracking-wide text-slate-900">业务链</h2>
|
||||
<span className="ml-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-100 text-sm font-bold tabular-nums leading-none text-neutral-500">
|
||||
{chains.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChainSectionExpanded((v) => !v)}
|
||||
title={chainSectionExpanded ? "收起业务链" : "展开业务链"}
|
||||
aria-expanded={chainSectionExpanded}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[#E6C9C4] bg-white text-neutral-400 transition-colors hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform duration-300 ease-out",
|
||||
chainSectionExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{chains.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{chains.map((chain) => {
|
||||
const selected = selectedChainId === chain.id;
|
||||
const graphOpen = expandedChainId === chain.id;
|
||||
const stageGroups = getChainStageGroups(chain);
|
||||
let seatColorIndex = 0;
|
||||
return (
|
||||
<div
|
||||
key={chain.id}
|
||||
className={cn(
|
||||
"rounded-xl border transition-all duration-200",
|
||||
selected
|
||||
? "border-primary/25 bg-primary/5 ring-1 ring-primary/20 shadow-sm"
|
||||
: "border-[#ECE7E4] bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="grid overflow-hidden transition-all duration-300 ease-out"
|
||||
style={{
|
||||
gridTemplateRows: chainSectionExpanded ? "1fr" : "0fr",
|
||||
marginTop: chainSectionExpanded ? 12 : 0,
|
||||
opacity: chainSectionExpanded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
{chains.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
|
||||
) : (
|
||||
<div className="max-h-[292px] overflow-y-auto pr-1 -mr-1 custom-scrollbar">
|
||||
<div className="flex flex-col gap-2.5 py-0.5">
|
||||
{chains.map((chain) => {
|
||||
const selected = selectedChainId === chain.id;
|
||||
const graphOpen = expandedChainId === chain.id;
|
||||
const stageGroups = getChainStageGroups(chain);
|
||||
let seatColorIndex = 0;
|
||||
return (
|
||||
<div
|
||||
key={chain.id}
|
||||
className={cn(
|
||||
"min-h-[88px] rounded-xl border transition-all duration-200",
|
||||
selected
|
||||
? "border-primary/25 bg-primary/5 ring-1 ring-primary/20 shadow-sm"
|
||||
: "border-[#ECE7E4] bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectChain(chain)}
|
||||
@@ -3189,11 +3232,14 @@ function RoundtableEntryRail({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3328,7 +3374,7 @@ function RoundtableEntryRail({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,21 +8,21 @@ import {
|
||||
|
||||
import { RouteBaseContext } from "@/core/route-base";
|
||||
|
||||
export function useRouter() {
|
||||
const navigate = useNavigate();
|
||||
const base = useContext(RouteBaseContext);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
push: (href: string) =>
|
||||
navigate(href.replace("/page/workspace", base)),
|
||||
replace: (href: string) =>
|
||||
navigate(href.replace("/page/workspace", base), { replace: true }),
|
||||
back: () => navigate(-1),
|
||||
forward: () => navigate(1),
|
||||
refresh: () => window.location.reload(),
|
||||
prefetch: async () => {},
|
||||
}),
|
||||
export function useRouter() {
|
||||
const navigate = useNavigate();
|
||||
const base = useContext(RouteBaseContext);
|
||||
const resolveHref = (href: string) =>
|
||||
href.startsWith(base) ? href : href.replace("/page/workspace", base);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
push: (href: string) => navigate(resolveHref(href)),
|
||||
replace: (href: string) => navigate(resolveHref(href), { replace: true }),
|
||||
back: () => navigate(-1),
|
||||
forward: () => navigate(1),
|
||||
refresh: () => window.location.reload(),
|
||||
prefetch: async () => {},
|
||||
}),
|
||||
[navigate, base],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "@/components/page-sdebar.css";
|
||||
import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls";
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { getStoredAuth, getAccountDisplayName } from "@/core/auth";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
@@ -124,6 +128,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
|
||||
const actionCluster = (
|
||||
<>
|
||||
<CompactShortcutButtons tone={isDark ? "banner-dark" : "banner-light"} />
|
||||
<ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} />
|
||||
|
||||
<LayoutModeSwitcher
|
||||
|
||||
@@ -15,6 +15,7 @@ import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql';
|
||||
import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
|
||||
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { copyTextToClipboard } from '@/core/clipboard';
|
||||
|
||||
export interface WikiLinkTarget {
|
||||
target: string;
|
||||
@@ -101,11 +102,11 @@ const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = (
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codeText);
|
||||
await copyTextToClipboard(codeText);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch (error) {
|
||||
console.error('复制代码失败:', error);
|
||||
} catch {
|
||||
// The shared clipboard helper already tries the browser fallback.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ interface ServerButton {
|
||||
function toLoginParam(p: ServerLoginParam): TaskButtonLoginParam {
|
||||
return {
|
||||
key: p.key ?? "",
|
||||
source: p.source || "accessToken",
|
||||
source: p.source || "access_token",
|
||||
customValue: p.custom_value ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
platformTypeChange,
|
||||
} from './GeneralQA.mock';
|
||||
import { requestCrudService } from '@/service/request-axios-service';
|
||||
import { authStorageGet } from '@/core/auth/scoped-storage';
|
||||
import { copyTextToClipboard } from '@/core/clipboard';
|
||||
|
||||
/**
|
||||
* 通用问答组件
|
||||
@@ -138,7 +140,7 @@ const GeneralQA: React.FC<{
|
||||
const mockSingleRoundStoppedRef = useRef(false);
|
||||
const mockMultiRoundStoppedRef = useRef(false);
|
||||
const chatListRef = useRef<HTMLDivElement | null>(null);
|
||||
const userId = useRef(localStorage.getItem('userId') || '387'); // 用户id
|
||||
const userId = useRef(authStorageGet('userId') || '387'); // 用户id
|
||||
const qaType = useRef('GENERAL'); // 问答类型
|
||||
const chatDataId = useRef(); // 聊天窗口id
|
||||
const compareChatId = useRef(); // 对比对话记录id
|
||||
@@ -1762,9 +1764,9 @@ const GeneralQA: React.FC<{
|
||||
const handleCopyAssistantMessage = async (message: Message) => {
|
||||
const answerText = getAssistantAnswerText(message);
|
||||
try {
|
||||
await navigator.clipboard.writeText(answerText);
|
||||
} catch (copyError) {
|
||||
console.error('复制模型回答失败:', copyError);
|
||||
await copyTextToClipboard(answerText);
|
||||
} catch {
|
||||
// Keep the chat surface quiet when the browser blocks clipboard access.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import {
|
||||
getKnowledgeIframeSkin,
|
||||
} from "@/core/strategy/external-links";
|
||||
import { authStorageGet } from "@/core/auth/scoped-storage";
|
||||
import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import ExternalIframePage from "../common/ExternalIframePage";
|
||||
|
||||
@@ -24,7 +25,7 @@ const KnowledgeBaseResourceManagement: React.FC = () => {
|
||||
|
||||
try {
|
||||
const userInfo = JSON.parse(jsonStr);
|
||||
const token = userInfo.accessToken;
|
||||
const token = normalizeExternalAccessToken(userInfo?.accessToken);
|
||||
if (!token) return;
|
||||
|
||||
const res = (await service.post(apiUrl, {
|
||||
|
||||
+13
-2
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/core/auth/task-purpose";
|
||||
import {
|
||||
fetchActionDetail,
|
||||
readActionDetail,
|
||||
readCopTaskDetail,
|
||||
type ActionDetail,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
@@ -61,12 +62,22 @@ function useChatsBase(taskBase: string): {
|
||||
const agentId = agent_id ?? "";
|
||||
const ctx = readTaskContext();
|
||||
const detail = readCopTaskDetail();
|
||||
const isXdfx = taskBase.includes("/plan-task-action");
|
||||
const cachedAction = isXdfx ? readActionDetail() : null;
|
||||
const fallbackTaskId =
|
||||
detail?.id == null ? "" : String(detail.id).trim();
|
||||
const fallbackActionId = cachedAction?.id ? String(cachedAction.id).trim() : "";
|
||||
const fallbackActionTaskId = cachedAction?.taskId ? String(cachedAction.taskId).trim() : "";
|
||||
const hasMatchingAction =
|
||||
!!fallbackActionId && !!fallbackActionTaskId && fallbackActionTaskId === fallbackTaskId;
|
||||
// 3qfx 偶发会出现 task context 丢失,但任务详情仍在 sessionStorage 的情况。
|
||||
// 这里回退到任务详情里的 taskId,避免左侧对话列表直接失效成“空任务态”。
|
||||
const taskId = ctx?.taskId ?? fallbackTaskId;
|
||||
const actionId = ctx?.partitionByAction ? (ctx.actionId ?? "") : "";
|
||||
const taskId = ctx?.taskId ?? (hasMatchingAction ? fallbackActionTaskId : fallbackTaskId);
|
||||
const actionId = ctx?.partitionByAction
|
||||
? (ctx.actionId ?? "")
|
||||
: hasMatchingAction
|
||||
? fallbackActionId
|
||||
: "";
|
||||
return { taskId, agentId, actionId, chatsBase: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -31,7 +31,10 @@ import {
|
||||
type TaskPurposeResult,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink";
|
||||
import { useRwfxSqGate } from "@/strategy-components/components/task-components/rwfx-sq-gate";
|
||||
import {
|
||||
useRwfxSqGate,
|
||||
type RwfxSqGateStartContext,
|
||||
} from "@/strategy-components/components/task-components/rwfx-sq-gate";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -164,7 +167,7 @@ export function TaskPurposePanel({
|
||||
}: {
|
||||
taskId: string;
|
||||
/** 闸门通过(无数据 / 覆盖成功),开始问答。 */
|
||||
onStart: () => void;
|
||||
onStart: (context?: RwfxSqGateStartContext) => void;
|
||||
/** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */
|
||||
autoStartWhenEmpty?: boolean;
|
||||
}) {
|
||||
@@ -209,7 +212,7 @@ export function TaskPurposePanel({
|
||||
|
||||
const handleStart = () => {
|
||||
if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖
|
||||
else onStart(); // 无数据 → 直接开始
|
||||
else gateStart(onStart); // 无数据 → 经过 3q 门控后开始
|
||||
};
|
||||
|
||||
const handleConfirmOverwrite = async () => {
|
||||
@@ -218,7 +221,7 @@ export function TaskPurposePanel({
|
||||
const r = await deleteTaskPurpose(taskId);
|
||||
if (r.success) {
|
||||
setConfirmOpen(false);
|
||||
onStart();
|
||||
gateStart(onStart);
|
||||
} else {
|
||||
toast.error(r.message || "无法覆盖,请稍后再试");
|
||||
setConfirmOpen(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* rwfx(任务分析)专用「3q 预检门控」。
|
||||
*
|
||||
* 进入任务分析工作区时调研判情报报告接口(``fetchSqReportStatus``)判断 3q 是否完成:
|
||||
* 进入任务分析工作区时调研判情报报告接口(``fetchSqReportCheck``)判断 3q 是否完成:
|
||||
* - ``incomplete``(3q 未完成 / 无数据)→ 弹「3q 未完成,是否前往 3qfx」框(暂不 / 前往 3qfx)。
|
||||
* - ``complete`` / ``unknown`` → 不打扰。
|
||||
*
|
||||
@@ -37,11 +37,18 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
|
||||
import { fetchSqReportCheck, type SqReportStatus } from "@/core/auth/dashboard-input";
|
||||
import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink";
|
||||
|
||||
type GateStatus = "loading" | "incomplete" | "ok";
|
||||
|
||||
export interface RwfxSqGateStartContext {
|
||||
/** 3q 研判情报报告状态。即使未完成/未知也会随 run context 传出,便于后端判断与本地验证。 */
|
||||
sqReportStatus: SqReportStatus;
|
||||
/** 已完成的 3q 研判情报报告。只随 run context 传给模型,不在页面展示。 */
|
||||
sqReport?: unknown;
|
||||
}
|
||||
|
||||
interface RwfxSqGateValue {
|
||||
/**
|
||||
* rwfx 自动开始第一次问答的门控。
|
||||
@@ -49,7 +56,7 @@ interface RwfxSqGateValue {
|
||||
* - 3q 未完成 → 挂起 ``start``,弹「前往 3qfx」框;用户点「暂不」才 ``start()``,点「前往 3qfx」跳转并丢弃。
|
||||
* 非 rwfx / 无 Provider → 直接 ``start()``(默认值)。
|
||||
*/
|
||||
gateStart: (start: () => void) => void;
|
||||
gateStart: (start: (context?: RwfxSqGateStartContext) => void) => void;
|
||||
}
|
||||
|
||||
const RwfxSqGateContext = createContext<RwfxSqGateValue>({
|
||||
@@ -72,56 +79,70 @@ export function RwfxSqGateProvider({
|
||||
const [jumping, setJumping] = useState(false);
|
||||
|
||||
const statusRef = useRef<GateStatus>(taskId ? "loading" : "ok");
|
||||
const sqReportStatusRef = useRef<SqReportStatus | null>(taskId ? null : "unknown");
|
||||
const sqReportRef = useRef<unknown | null>(null);
|
||||
// 待触发的「开始问答」回调:3q 未完成时挂起,点「暂不」/关闭弹框后触发。
|
||||
const pendingStartRef = useRef<(() => void) | null>(null);
|
||||
const pendingStartRef = useRef<((context?: RwfxSqGateStartContext) => void) | null>(null);
|
||||
|
||||
const buildStartContext = useCallback((): RwfxSqGateStartContext | undefined => {
|
||||
const sqReportStatus = sqReportStatusRef.current;
|
||||
if (!sqReportStatus) return undefined;
|
||||
const sqReport = sqReportRef.current;
|
||||
return sqReport == null ? { sqReportStatus } : { sqReportStatus, sqReport };
|
||||
}, []);
|
||||
|
||||
// taskId 变化:重置门控状态并预检 3q。
|
||||
useEffect(() => {
|
||||
pendingStartRef.current = null;
|
||||
sqReportStatusRef.current = null;
|
||||
sqReportRef.current = null;
|
||||
setOpen(false);
|
||||
if (!taskId) {
|
||||
statusRef.current = "ok";
|
||||
sqReportStatusRef.current = "unknown";
|
||||
return;
|
||||
}
|
||||
statusRef.current = "loading";
|
||||
let alive = true;
|
||||
void fetchSqReportStatus(taskId).then((s) => {
|
||||
void fetchSqReportCheck(taskId).then((result) => {
|
||||
if (!alive) return;
|
||||
// complete / unknown 都按「放行」(不打扰);仅明确 incomplete 才门控。
|
||||
const next: GateStatus = s === "incomplete" ? "incomplete" : "ok";
|
||||
const next: GateStatus = result.status === "incomplete" ? "incomplete" : "ok";
|
||||
statusRef.current = next;
|
||||
sqReportStatusRef.current = result.status;
|
||||
sqReportRef.current = result.status === "complete" ? (result.report ?? null) : null;
|
||||
if (next === "incomplete") {
|
||||
setOpen(true); // 进入即提醒(无论是否有挂起的自动发送)
|
||||
} else if (pendingStartRef.current) {
|
||||
// 加载期间已请求自动开始 → 放行后补发。
|
||||
const start = pendingStartRef.current;
|
||||
pendingStartRef.current = null;
|
||||
start();
|
||||
start(buildStartContext());
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
}, [buildStartContext, taskId]);
|
||||
|
||||
const gateStart = useCallback((start: () => void) => {
|
||||
const gateStart = useCallback((start: (context?: RwfxSqGateStartContext) => void) => {
|
||||
const status = statusRef.current;
|
||||
if (status === "ok") {
|
||||
start();
|
||||
start(buildStartContext());
|
||||
return;
|
||||
}
|
||||
// loading / incomplete:挂起。loading 解析为 ok 时补发;incomplete 时等用户「暂不」。
|
||||
pendingStartRef.current = start;
|
||||
if (status === "incomplete") setOpen(true);
|
||||
}, []);
|
||||
}, [buildStartContext]);
|
||||
|
||||
/** 暂不(不去 3q 分析)/ 关闭弹框 → 补发挂起的「开始问答」(无则无操作)。 */
|
||||
const stayAndStart = useCallback(() => {
|
||||
setOpen(false);
|
||||
const start = pendingStartRef.current;
|
||||
pendingStartRef.current = null;
|
||||
start?.();
|
||||
}, []);
|
||||
start?.(buildStartContext());
|
||||
}, [buildStartContext]);
|
||||
|
||||
const handleGo3qfx = async () => {
|
||||
const detail = readCopTaskDetail();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/light-apps";
|
||||
import { authStorageGet } from "@/core/auth/scoped-storage";
|
||||
import { readCurrentExternalAccessTokenValue } from "@/core/auth/external-access-token";
|
||||
|
||||
/** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */
|
||||
export const MOUNT_OPTIONS: { label: string; value: string }[] = [
|
||||
@@ -28,7 +29,8 @@ export function mountLabel(value: string | null | undefined): string {
|
||||
* userName / gateDepartName / typeName 取 consumer 接口 userDetailByYuserId 的返回值。
|
||||
*/
|
||||
export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: "accessToken", value: "accessToken" },
|
||||
{ label: "access_token", value: "access_token" },
|
||||
{ label: "accessToken(兼容旧配置)", value: "accessToken" },
|
||||
{ label: "yUserId", value: "yUserId" },
|
||||
{ label: "userName", value: "userName" },
|
||||
{ label: "gateDepartName", value: "gateDepartName" },
|
||||
@@ -142,6 +144,9 @@ export function resolveRouteParamValue(
|
||||
}
|
||||
if (param.source) {
|
||||
const value = userInfo[param.source];
|
||||
if (param.source === "access_token" || param.source === "accessToken") {
|
||||
return readCurrentExternalAccessTokenValue(userInfo);
|
||||
}
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
return "";
|
||||
@@ -153,15 +158,50 @@ export function resolveRouteParamValue(
|
||||
* 兼容 hash 路由地址(如 `https://x#/route`)—— 管理员粘贴完整地址,参数自动追加在末尾,
|
||||
* 落在 hash 路由的 query 上(与现有 XK/PG 页面 `?accessToken=` 拼接方式一致)。
|
||||
*/
|
||||
function appendQueryPairs(url: string, pairs: string[]): string {
|
||||
if (pairs.length === 0) return url;
|
||||
const hashIndex = url.indexOf("#");
|
||||
if (hashIndex >= 0) {
|
||||
const beforeHash = url.slice(0, hashIndex);
|
||||
const hash = url.slice(hashIndex);
|
||||
const sep = hash.includes("?") ? "&" : "?";
|
||||
return `${beforeHash}${hash}${sep}${pairs.join("&")}`;
|
||||
}
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${url}${sep}${pairs.join("&")}`;
|
||||
}
|
||||
|
||||
function replaceAccessTokenParam(url: string, accessToken: string): { url: string; replaced: boolean } {
|
||||
if (!accessToken) return { url, replaced: false };
|
||||
let replaced = false;
|
||||
const nextUrl = url.replace(/([?&]access_token)(?:=[^&#]*)?(?=[&#]|$)/g, (match) => {
|
||||
replaced = true;
|
||||
return `${match.split("=")[0]}=${encodeURIComponent(accessToken)}`;
|
||||
});
|
||||
return { url: nextUrl, replaced };
|
||||
}
|
||||
|
||||
export function buildLightAppUrl(
|
||||
app: Pick<LightApp, "url" | "routeParams">,
|
||||
userInfo: Record<string, unknown> = readUserInfo(),
|
||||
theme?: AppThemeColor,
|
||||
): string {
|
||||
const pairs = (app.routeParams ?? [])
|
||||
.filter((p) => p.key)
|
||||
.map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo, theme))}`);
|
||||
if (pairs.length === 0) return app.url;
|
||||
const sep = app.url.includes("?") ? "&" : "?";
|
||||
return `${app.url}${sep}${pairs.join("&")}`;
|
||||
const routeParams = app.routeParams ?? [];
|
||||
const accessToken = readCurrentExternalAccessTokenValue(userInfo);
|
||||
const tokenUrlResult = replaceAccessTokenParam(app.url, accessToken);
|
||||
const pairs = routeParams
|
||||
.filter((p) => (p.key ?? "").trim())
|
||||
.filter((p) => (p.key ?? "").trim() !== "access_token" || !tokenUrlResult.replaced)
|
||||
.map((p) => {
|
||||
const key = (p.key ?? "").trim();
|
||||
const value = key === "access_token" ? accessToken : resolveRouteParamValue(p, userInfo, theme);
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
});
|
||||
const hasAccessTokenParam =
|
||||
tokenUrlResult.replaced ||
|
||||
routeParams.some((p) => (p.key ?? "").trim() === "access_token");
|
||||
if (!hasAccessTokenParam && accessToken) {
|
||||
pairs.push(`${encodeURIComponent("access_token")}=${encodeURIComponent(accessToken)}`);
|
||||
}
|
||||
return appendQueryPairs(tokenUrlResult.url, pairs);
|
||||
}
|
||||
Reference in new issue
Block a user