# 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:
董宏凯 committed 2026-07-03 22:28:21 +08:00
commit 221e947488
138 files changed
+65056 -2405

No files matched your search

+2 -2
View File
@@ -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>
</>
);
}
+18 -6
View File
@@ -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;
}
+1 -1
View File
@@ -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 {
+7 -3
View File
@@ -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);
}
/**
+204 -24
View File
@@ -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);
}
}
+44
View File
@@ -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");
});
+146 -26
View File
@@ -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;
+54 -16
View File
@@ -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,
+210 -68
View File
@@ -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,
),
);
},
});
}
+7 -6
View File
@@ -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>
+87 -10
View File
@@ -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>
+2
View File
@@ -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
+21 -2
View File
@@ -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">
+163 -28
View File
@@ -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();
}
+100 -9
View File
@@ -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>
);
}
+21 -2
View File
@@ -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">
+11
View File
@@ -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={
+1 -1
View File
@@ -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)));
+89 -7
View File
@@ -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>
);
}
+15 -15
View File
@@ -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.
}
};
@@ -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, {
@@ -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` };
}
@@ -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);
}
@@ -281,6 +281,8 @@ Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship
**Skill-driven retrieval (检索类型).** `material_source` has three values: `general` (通用检索, default), `knowledge_base` (知识库检索), and `notebook` (我的空间); legacy `skill` is normalized to `general` by the researcher node. **`knowledge_base` 直连内网 ES 接口(= 旧版「内网直连检索」)** — it bypasses skill orchestration entirely and calls `IntranetSearchProvider` over `ai_writing.intranet_search_url` (concurrent over the generated keywords; `intranet_knowledge_base`/`intranet_search_timeout` apply). **An empty/unconfigured `intranet_search_url` does NOT disable it** — the empty string is passed straight to `IntranetSearchProvider`, which falls back to its built-in default endpoint (`intranet_stub._DEFAULT_ENDPOINT`), exactly like the legacy `get_search_provider(search_provider=intranet)` path. So 内网直连检索 works out of the box without configuring an address (configure `intranet_search_url` only to point at a different endpoint). This is the "弱模型识别不了技能" escape hatch — the old no-skill retrieval path restored as an explicit option (weak models like deepseek-chat often fail to invoke configured skills). Tests: `tests/test_ai_writing_knowledge_base.py`. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). **Skill-failure fault tolerance**: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a `skill_search_step` of type `skill_error` (carrying the error `reason`), and if the configured skills collected **no** new materials at all it runs a `fallback` pass over other available skills (`_fallback_candidate_skills`: the built-in `knowledge-base-search` native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend `SkillStepsCard` renders `skill_error` (rose) and `fallback` (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: `tests/test_ai_writing_material_source.py`.
The intranet client uses `trust_env=False` and defaults `intranet_verify_ssl=false`, so internal HTTPS endpoints with self-signed/internal-CA certificates do not fail on certificate verification; set `ai_writing.intranet_verify_ssl: true` only for environments that require strict certificate checks.
**Skill-call timeout** is hardcoded **300s (5 min)** — `researcher.py` `_SKILL_SEARCH_TIMEOUT_SECONDS` (per-skill wrapper) and `_skill_agent.py` `_REAL_AGENT_TIMEOUT_SECONDS` (whole real-agent skill run). The intranet ES fallback (`IntranetSearchProvider`) gets a longer **600s (10 min)** default (`intranet_stub.py`; overridable via `ai_writing.intranet_search_timeout`) — that interface is the slow one, so our own fallback timeout is generous to avoid being blamed for "检不出".
**Parallel intranet fallback (技能 0 条 → 直接用并行预跑的内网结果).** When `ai_writing.intranet_search_url` is configured, the researcher node launches the intranet ES search (`IntranetSearchProvider` over the generated keywords) as a **concurrent `asyncio.Task` the moment the skill loop starts** (not sequentially after all skills fail). If the configured + fallback skills collect **nothing**, the node awaits that already-running task and uses its results (`_collect`) — no extra wait. If the skills **did** get materials, the task is cancelled (`_cancel_intranet_task`, suppresses CancelledError) so the intranet call isn't wasted. URL empty → no task, behavior unchanged. Tests: `tests/test_ai_writing_intranet_parallel.py`.
@@ -138,6 +138,10 @@ AI writing supports a sample-imitation mode: the frontend can upload or paste a
Custom agents are indexed in the `agents` database table. The `id` column is the stable runtime identifier and filesystem directory name (`.deer-flow/agents/{id}/SOUL.md`); `name` is display-only, may be Chinese, and may be duplicated. Rows with `user_id = NULL` are built-in agents, user rows are private unless `published = true`, and users can only update or delete agents they created.
### AI Writing Intranet Retrieval
AI 写作的「知识库检索」会直连 `ai_writing.intranet_search_url` 内网 ES/知识库接口,并默认跳过代理环境变量和 HTTPS 证书校验,适配自签名或内部 CA 证书的内网服务。需要强校验时可在 `config.yaml` 里设置 `ai_writing.intranet_verify_ssl: true`。
### IM Channels
The IM bridge supports Feishu, Slack, and Telegram. Slack and Telegram still use the final `runs.wait()` response path, while Feishu now streams through `runs.stream(["messages-tuple", "values"])` and updates a single in-thread card in place.
@@ -54,6 +54,7 @@ class ActiveRunItem(BaseModel):
user_name: str = ""
agent_id: str = ""
agent_name: str = ""
model_name: str = ""
thread_title: str = ""
kind: str = "对话"
multitask_strategy: str = "reject"
@@ -245,6 +246,7 @@ async def list_active_runs(request: Request) -> ActiveRunsResponse:
user_name=user_emails.get(user_id) or user_id or "未知用户",
agent_id=assistant_id,
agent_name=agent_name,
model_name=str((r.metadata or {}).get("model_name") or ""),
thread_title=(meta_row.display_name if meta_row else "") or "",
kind=_kind_for(metadata),
multitask_strategy=r.multitask_strategy,
@@ -4,9 +4,11 @@ import logging
import mimetypes
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -220,6 +222,7 @@ class SkillInstallRequest(BaseModel):
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
skill_name: str | None = Field(default=None, description="Optional custom install name. When set, rewrites the archive SKILL.md frontmatter name before install.")
target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
@@ -233,6 +236,32 @@ class SkillInstallResponse(BaseModel):
category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'")
class SkillInstallPreviewRequest(BaseModel):
"""Request model for preflighting a .skill artifact install."""
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
path: str = Field(..., description="Virtual path to the .skill file")
target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
class SkillInstallPreviewResponse(BaseModel):
"""Response model for skill install preflight."""
ok: bool = Field(..., description="Whether the archive could be parsed")
skill_name: str = Field(..., description="Skill name parsed from the archive frontmatter")
description: str = Field(default="", description="Parsed description from the archive frontmatter")
license: str | None = Field(default=None, description="Parsed license from the archive frontmatter")
allowed_tools: list[str] = Field(default_factory=list, description="Allowed tools declared in the archive")
can_install_as_is: bool = Field(default=True, description="Whether the current user may install the archive without renaming")
owned_by_me: bool = Field(default=False, description="True when the existing conflicting skill is already owned by the caller")
owned_by_other: bool = Field(default=False, description="True when another user already owns the conflicting custom skill")
builtin_conflict: bool = Field(default=False, description="True when the parsed name collides with a built-in/public skill")
owner_username: str | None = Field(default=None, description="Friendly owner username for owned_by_other conflicts")
owner_user_id: str | None = Field(default=None, description="Owner user id for custom conflicts")
message: str = Field(default="", description="Human-readable install guidance for the UI")
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings from validation")
class SkillUploadValidateResponse(BaseModel):
"""Response model for the pre-install validation endpoint."""
@@ -1040,6 +1069,162 @@ async def install_skill_upload(
tmp_path.unlink(missing_ok=True)
async def _preview_install_target(
*,
request: Request,
skill_store: SkillStore,
skill_name: str,
target_category: SkillCategory,
) -> tuple[bool, bool, bool, bool, str | None, str | None, str]:
"""Return installability / ownership info for a parsed skill name."""
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
existing = await skill_store.get_any(skill_name)
owner_user_id = existing.get("owner_user_id") if existing else None
owner_username: str | None = None
if owner_user_id:
try:
attached = await _attach_owner_usernames(
[SkillResponse(name=skill_name, description="", license=None, category=SkillCategory.CUSTOM, owner_user_id=owner_user_id)]
)
owner_username = attached[0].owner_username if attached else None
except Exception:
owner_username = None
builtin_conflict = bool(existing and owner_user_id is None)
owned_by_me = bool(existing and owner_user_id == user_id)
owned_by_other = bool(existing and owner_user_id not in (None, user_id))
if target_category == SkillCategory.PUBLIC and not is_admin:
return False, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, "Only admin users can install skills to the public directory"
if builtin_conflict:
return False, owned_by_me, owned_by_other, True, owner_user_id, owner_username, f"Skill '{skill_name}' already exists as a built-in/public skill. Please install it under a different name."
if owned_by_other:
who = owner_username or "another user"
return False, owned_by_me, True, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' has already been installed by {who}. Please choose a different name."
if owned_by_me:
return False, True, False, builtin_conflict, owner_user_id, owner_username, f"You have already installed skill '{skill_name}'. You can keep using it or install this copy under a different name."
return True, False, False, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' can be installed."
async def _build_renamed_skill_archive(
archive_path: str | Path,
*,
new_name: str,
require_skill_suffix: bool,
) -> Path:
"""Create a temporary archive whose SKILL.md frontmatter name is rewritten."""
from deerflow.skills.installer import resolve_skill_dir_from_archive, safe_extract_skill_archive
SkillStorage.validate_skill_name(new_name)
tmp_root = Path(tempfile.mkdtemp(prefix="skill-install-rename-"))
try:
path = Path(archive_path)
if require_skill_suffix and path.suffix != ".skill":
raise ValueError("File must have .skill extension")
with zipfile.ZipFile(path, "r") as zf:
safe_extract_skill_archive(zf, tmp_root)
skill_dir = resolve_skill_dir_from_archive(tmp_root)
skill_md = skill_dir / SKILL_MD_FILE
skill_md.write_text(
_rewrite_skill_frontmatter_name(skill_md.read_text(encoding="utf-8"), new_name),
encoding="utf-8",
)
renamed_archive = tmp_root / f"{new_name}.skill"
with zipfile.ZipFile(renamed_archive, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in sorted(tmp_root.rglob("*")):
if not path.is_file() or path == renamed_archive:
continue
zf.write(path, path.relative_to(tmp_root))
return renamed_archive
except Exception:
shutil.rmtree(tmp_root, ignore_errors=True)
raise
async def _install_archive_with_optional_name(
*,
storage: SkillStorage,
archive_path: str | Path,
requested_name: str | None,
target_category: SkillCategory,
overwrite: bool = False,
require_skill_suffix: bool = True,
) -> SkillInstallResponse:
normalized_requested = SkillStorage.validate_skill_name(requested_name) if requested_name else None
temp_root: Path | None = None
install_source = Path(archive_path)
if normalized_requested:
renamed_archive = await _build_renamed_skill_archive(
archive_path,
new_name=normalized_requested,
require_skill_suffix=require_skill_suffix,
)
temp_root = renamed_archive.parent
install_source = renamed_archive
try:
result = await storage.ainstall_skill_from_archive(
install_source,
overwrite=overwrite,
require_skill_suffix=require_skill_suffix,
target_category=target_category,
)
return SkillInstallResponse(**result)
finally:
if temp_root is not None:
shutil.rmtree(temp_root, ignore_errors=True)
@router.post(
"/skills/install-preview",
response_model=SkillInstallPreviewResponse,
summary="Preview Skill Install",
description="Parse a .skill artifact from a thread and return ownership/conflict info before install.",
)
async def preview_skill_install(
body: SkillInstallPreviewRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillInstallPreviewResponse:
try:
skill_file_path = await aresolve_thread_virtual_path(body.thread_id, body.path)
validation = get_or_new_skill_storage(app_config=config).validate_skill_archive(skill_file_path, require_skill_suffix=False)
skill_name = str(validation.get("skill_name") or "")
can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, message = await _preview_install_target(
request=request,
skill_store=skill_store,
skill_name=skill_name,
target_category=body.target_category,
)
return SkillInstallPreviewResponse(
ok=True,
skill_name=skill_name,
description=str(validation.get("description") or ""),
license=validation.get("license"),
allowed_tools=[str(item) for item in (validation.get("allowed_tools") or [])],
can_install_as_is=can_install_as_is,
owned_by_me=owned_by_me,
owned_by_other=owned_by_other,
builtin_conflict=builtin_conflict,
owner_username=owner_username,
owner_user_id=owner_user_id,
message=message,
warnings=[str(item) for item in (validation.get("warnings") or [])],
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to preview skill install: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to preview skill install: {e}")
@router.post(
"/skills/install",
response_model=SkillInstallResponse,
@@ -1056,25 +1241,46 @@ async def install_skill(
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
target_category = body.target_category
requested_name = SkillStorage.validate_skill_name(body.skill_name) if body.skill_name else None
if target_category == SkillCategory.PUBLIC and not is_admin:
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
skill_file_path = await aresolve_thread_virtual_path(body.thread_id, body.path)
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
storage = get_or_new_skill_storage(app_config=config)
validation = storage.validate_skill_archive(skill_file_path, require_skill_suffix=False)
preview_name = requested_name or str(validation.get("skill_name") or "")
can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, _owner_user_id, _owner_username, message = await _preview_install_target(
request=request,
skill_store=skill_store,
skill_name=preview_name,
target_category=target_category,
)
if not can_install_as_is:
if requested_name and requested_name != validation.get("skill_name") and not owned_by_other and not builtin_conflict:
pass
else:
raise HTTPException(status_code=409, detail=message)
result = await _install_archive_with_optional_name(
storage=storage,
archive_path=skill_file_path,
requested_name=requested_name,
target_category=target_category,
require_skill_suffix=False,
)
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(result["skill_name"])
mark_uploaded(result.skill_name)
except Exception:
pass
if target_category == SkillCategory.PUBLIC:
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": None, "published": True}
{"name": result.skill_name, "owner_user_id": None, "published": True}
)
else:
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
{"name": result.skill_name, "owner_user_id": user_id, "published": False}
)
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result)
return result
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except SkillAlreadyExistsError as e:
@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
from typing import Literal
from urllib.parse import urlparse
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
@@ -12,7 +14,12 @@ from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_optional_user_from_request
from deerflow.config import get_app_config
from deerflow.config.system_settings import (
AppearanceRouteParam,
CompactShortcutButton,
LeaderboardSettings,
ModelConcurrencySettings,
PluginDomainAgentMapping,
PluginManagementSettings,
PromptPrefixSettings,
PublishSquare,
PublishSquaresSettings,
@@ -42,6 +49,21 @@ class PromptPrefixUpdateRequest(BaseModel):
prompt_prefix: str | None = None
class PluginDomainAgentMappingItem(BaseModel):
id: str = Field(..., description="Stable row id")
domain: str = Field(..., description="Normalized host matched against pageContext.url")
agent_id: str = Field(..., description="Agent id selected for the host")
enabled: bool = Field(default=True, description="Whether the mapping is active")
class PluginManagementResponse(BaseModel):
domain_agent_mappings: list[PluginDomainAgentMappingItem] = Field(default_factory=list)
class PluginManagementUpdateRequest(BaseModel):
domain_agent_mappings: list[PluginDomainAgentMappingItem] | None = None
class PrivateSquareStatusResponse(BaseModel):
"""Status of the private-square feature, readable by any logged-in user.
@@ -90,6 +112,34 @@ def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
)
def _normalize_plugin_domain(value: str | None) -> str:
raw = (value or "").strip()
if not raw:
return ""
candidate = raw if "://" in raw else f"https://{raw}"
try:
parsed = urlparse(candidate)
except Exception:
return ""
return (parsed.netloc or parsed.path or "").strip().lower()
def _plugin_management_to_response(
settings: PluginManagementSettings,
) -> PluginManagementResponse:
return PluginManagementResponse(
domain_agent_mappings=[
PluginDomainAgentMappingItem(
id=item.id,
domain=item.domain,
agent_id=item.agent_id,
enabled=item.enabled,
)
for item in settings.domain_agent_mappings
]
)
async def _require_admin(request: Request) -> None:
user = await get_optional_user_from_request(request)
if user is None:
@@ -116,6 +166,50 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
return _to_response(saved.prompt_prefix)
@router.get("/plugin-management", response_model=PluginManagementResponse)
async def get_plugin_management() -> PluginManagementResponse:
"""Return the iframe plugin domain -> agent mappings."""
return _plugin_management_to_response(load_system_settings().plugin_management)
@router.put("/plugin-management", response_model=PluginManagementResponse)
async def update_plugin_management(
body: PluginManagementUpdateRequest, request: Request
) -> PluginManagementResponse:
"""Replace the iframe plugin domain -> agent mappings. Admin only."""
from uuid import uuid4
await _require_admin(request)
current = load_system_settings()
mappings: list[PluginDomainAgentMapping] = []
seen_domains: set[str] = set()
for item in body.domain_agent_mappings or []:
domain = _normalize_plugin_domain(item.domain)
agent_id = (item.agent_id or "").strip()
if not domain or not agent_id:
continue
if domain in seen_domains:
continue
seen_domains.add(domain)
mappings.append(
PluginDomainAgentMapping(
id=(item.id or "").strip() or uuid4().hex,
domain=domain,
agent_id=agent_id,
enabled=bool(item.enabled),
)
)
updated = current.plugin_management.model_copy(
update={"domain_agent_mappings": mappings}
)
saved = save_system_settings(
current.model_copy(update={"plugin_management": updated})
)
return _plugin_management_to_response(saved.plugin_management)
class SkillCompressionResponse(BaseModel):
enabled: bool = Field(..., description="Whether skill compression is active")
search_top_n: int = Field(..., description="Max skills returned by the search_skills tool")
@@ -128,6 +222,18 @@ class SkillCompressionUpdateRequest(BaseModel):
keep_index_in_prompt: bool | None = None
class ModelConcurrencyResponse(BaseModel):
enabled: bool = Field(..., description="Whether per-model concurrency limiting is active")
default_limit: int = Field(..., description="Fallback max concurrent runs for models without an explicit override")
model_limits: dict[str, int] = Field(default_factory=dict, description="Per-model overrides: model_name -> max concurrent runs")
class ModelConcurrencyUpdateRequest(BaseModel):
enabled: bool | None = None
default_limit: int | None = Field(default=None, ge=1, le=10_000)
model_limits: dict[str, int] | None = None
def _skill_compression_to_response(settings: SkillCompressionSettings) -> SkillCompressionResponse:
return SkillCompressionResponse(
enabled=settings.enabled,
@@ -136,6 +242,26 @@ def _skill_compression_to_response(settings: SkillCompressionSettings) -> SkillC
)
def _model_concurrency_to_response(settings: ModelConcurrencySettings) -> ModelConcurrencyResponse:
cleaned: dict[str, int] = {}
for name, limit in (settings.model_limits or {}).items():
model_name = str(name).strip()
if not model_name:
continue
try:
normalized_limit = int(limit)
except (TypeError, ValueError):
continue
if normalized_limit < 1:
continue
cleaned[model_name] = min(normalized_limit, 10_000)
return ModelConcurrencyResponse(
enabled=settings.enabled,
default_limit=max(1, min(int(settings.default_limit), 10_000)),
model_limits=cleaned,
)
@router.get("/skill-compression", response_model=SkillCompressionResponse)
async def get_skill_compression() -> SkillCompressionResponse:
"""Return the skill-compression configuration (readable by any user).
@@ -174,6 +300,47 @@ async def update_skill_compression(body: SkillCompressionUpdateRequest, request:
return _skill_compression_to_response(saved.skill_compression)
@router.get("/model-concurrency", response_model=ModelConcurrencyResponse)
async def get_model_concurrency(request: Request) -> ModelConcurrencyResponse:
"""Return the per-model concurrency-limit configuration. Admin only."""
await _require_admin(request)
return _model_concurrency_to_response(load_system_settings().model_concurrency)
@router.put("/model-concurrency", response_model=ModelConcurrencyResponse)
async def update_model_concurrency(
body: ModelConcurrencyUpdateRequest,
request: Request,
) -> ModelConcurrencyResponse:
"""Update the per-model concurrency-limit configuration. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "enabled" in fields and fields["enabled"] is not None:
update["enabled"] = bool(fields["enabled"])
if "default_limit" in fields and fields["default_limit"] is not None:
update["default_limit"] = max(1, min(int(fields["default_limit"]), 10_000))
if "model_limits" in fields and fields["model_limits"] is not None:
cleaned: dict[str, int] = {}
for name, limit in (body.model_limits or {}).items():
model_name = str(name).strip()
if not model_name:
continue
try:
normalized_limit = int(limit)
except (TypeError, ValueError):
continue
if normalized_limit < 1:
continue
cleaned[model_name] = min(normalized_limit, 10_000)
update["model_limits"] = cleaned
settings = current.model_concurrency.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"model_concurrency": settings}))
return _model_concurrency_to_response(saved.model_concurrency)
def _leaderboard_to_response(settings: LeaderboardSettings) -> LeaderboardSettingsResponse:
return LeaderboardSettingsResponse(
exclude_user_ids=list(dict.fromkeys([uid for uid in settings.exclude_user_ids if uid])),
@@ -373,6 +540,7 @@ class AppearanceResponse(BaseModel):
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)")
compact_shortcut_buttons: list[CompactShortcutButton] = Field(default_factory=list, description="Shortcut buttons rendered in top bars")
class AppearanceUpdateRequest(BaseModel):
@@ -380,6 +548,61 @@ class AppearanceUpdateRequest(BaseModel):
default_skill_view: Literal["card", "list"] | None = Field(default=None)
default_agent_view: Literal["card", "list"] | None = Field(default=None)
default_layout_mode: Literal["full", "compact"] | None = Field(default=None)
compact_shortcut_buttons: list[CompactShortcutButton] | None = Field(default=None)
def _normalize_compact_shortcut_buttons(
buttons: list[CompactShortcutButton] | None,
) -> list[CompactShortcutButton]:
normalized: list[CompactShortcutButton] = []
seen_ids: set[str] = set()
for index, item in enumerate(buttons or []):
label = (item.label or "").strip() or "快捷入口"
url = (item.url or "").strip()
row_id = (item.id or "").strip() or uuid4().hex
if row_id in seen_ids:
row_id = uuid4().hex
seen_ids.add(row_id)
route_params: list[AppearanceRouteParam] = []
for param in item.route_params or []:
key = (param.key or "").strip()
if not key:
continue
if param.paramType == "theme":
route_params.append(
AppearanceRouteParam(
key=key,
paramType="theme",
themeValue=(param.themeValue or "").strip()
or "red=red;blue=blue;dark-blue=dark-blue",
)
)
continue
source = (param.source or "access_token").strip() or "access_token"
route_params.append(
AppearanceRouteParam(
key=key,
paramType="login",
source=source,
customValue=(param.customValue or "").strip()
if source == "other"
else None,
)
)
normalized.append(
CompactShortcutButton(
id=row_id,
label=label,
url=url,
route_params=route_params,
enabled=bool(item.enabled),
sort_order=index,
)
)
return normalized
def _appearance_response(appearance) -> AppearanceResponse:
@@ -388,6 +611,10 @@ def _appearance_response(appearance) -> AppearanceResponse:
default_skill_view=appearance.default_skill_view,
default_agent_view=appearance.default_agent_view,
default_layout_mode=appearance.default_layout_mode,
compact_shortcut_buttons=sorted(
appearance.compact_shortcut_buttons,
key=lambda item: item.sort_order,
),
)
@@ -410,6 +637,10 @@ async def update_appearance_settings(
for key in ("default_theme", "default_skill_view", "default_agent_view", "default_layout_mode"):
if key in fields and fields[key] is not None:
update[key] = fields[key]
if "compact_shortcut_buttons" in fields and body.compact_shortcut_buttons is not None:
update["compact_shortcut_buttons"] = _normalize_compact_shortcut_buttons(
body.compact_shortcut_buttons
)
if update:
updated_appearance = current.appearance.model_copy(update=update)
saved = save_system_settings(
File diff suppressed because it is too large. Load diff
@@ -127,6 +127,31 @@ def _fallback_knowledge_skill() -> SimpleNamespace:
return SimpleNamespace(name=KNOWLEDGE_SEARCH_SKILL_NAME, description=_KNOWLEDGE_SKILL_FALLBACK_DESC)
def _unique_limited(values: list, limit: int) -> list:
"""Stable de-dup for short diagnostic lists."""
return list(dict.fromkeys(values or []))[:limit]
def _intranet_query_summary(rows: list[dict], limit: int = 5) -> str:
"""Compact per-query stats for the visible skill step message."""
parts: list[str] = []
for row in rows[:limit]:
query = str(row.get("query") or "")[:24] or "(空)"
elapsed_ms = row.get("elapsed_ms")
try:
elapsed = f"{float(elapsed_ms) / 1000:.1f}s" if elapsed_ms is not None else ""
except (TypeError, ValueError):
elapsed = ""
if row.get("error"):
reason = str(row.get("error"))[:80]
parts.append(f"{query}: 失败{f'/{elapsed}' if elapsed else ''}({reason})")
else:
parts.append(f"{query}: {int(row.get('count') or 0)} 条{f'/{elapsed}' if elapsed else ''}")
if len(rows) > limit:
parts.append(f"另 {len(rows) - limit} 个方向")
return ";".join(parts)
def _load_configured_skills() -> list:
"""解析素材收集专家 agent(ai-writing-researcher)配置的技能列表。
@@ -370,7 +395,10 @@ async def _search_notebook(
results: list[Material] = []
mat_idx = 0
try:
async with httpx.AsyncClient(timeout=20) as client:
# trust_env=False:笔记后端是内网/本机地址,但模型走公网,部署常设
# HTTP_PROXY/HTTPS_PROXY;httpx 默认会把这个内网请求也走代理 → 不可达。
# 强制忽略代理 env、直连(与内网 ES 检索同款修法)。
async with httpx.AsyncClient(timeout=20, trust_env=False) as client:
for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]:
logger.info("[notebook-search] POST kw=%r body=%s", kw, {
"query": kw,
@@ -657,8 +685,24 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 内网慢检索超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,
# 默认 600s,且最低 300s。
timeout=_intranet_timeout_seconds(ai_writing_config),
verify_ssl=ai_writing_config.get("intranet_verify_ssl", False),
)
endpoint_label = intranet_url or getattr(provider, "endpoint", "内置默认内网端点")
intranet_debug = {
"mode": "knowledge_base",
"configured": bool(intranet_url),
"endpoint": endpoint_label,
"knowledge_base": ai_writing_config.get("intranet_knowledge_base", "samples"),
"timeout": _intranet_timeout_seconds(ai_writing_config),
"verify_ssl": getattr(provider, "verify_ssl", False),
"status": "running",
"queries": [],
"calls": [],
"errors": [],
"raw_responses": [],
"result_count": 0,
"used_as_fallback": False,
}
# 用户输入框追加(is_user_query_search)时 keywords 已是 [用户原话],全部检索;
# 首次检索按 keyword_count 截断检索词。
search_keywords = keywords if is_user_query_search else keywords[:keyword_count]
@@ -670,20 +714,65 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"message": f"使用「{_KNOWLEDGE_SKILL_DISPLAY}」直连内网接口({endpoint_label})检索 {total} 个方向:{('、'.join(search_keywords[:3]))[:60]}",
"skill_name": KNOWLEDGE_SEARCH_SKILL_NAME,
})
async def _search_one_keyword(kw: str) -> tuple[str, list[SearchResult], str, int]:
started = time.monotonic()
try:
results = await provider.search(kw, max_results=per_keyword)
return kw, list(results or []), "", int((time.monotonic() - started) * 1000)
except Exception as e: # noqa: BLE001 — provider 理论上会吞异常,这里兜住意外冒泡
logger.warning("[knowledge-base] keyword=%r search failed: %s", kw, e)
return kw, [], f"{type(e).__name__}: {e}", int((time.monotonic() - started) * 1000)
gathered = await asyncio.gather(
*[provider.search(kw, max_results=per_keyword) for kw in search_keywords],
*[_search_one_keyword(kw) for kw in search_keywords],
return_exceptions=True,
)
for results in gathered:
if isinstance(results, Exception):
logger.warning("[knowledge-base] search failed: %s", results)
search_errors.append(f"{type(results).__name__}: {results}")
for item in gathered:
if isinstance(item, Exception):
logger.warning("[knowledge-base] search task failed: %s", item)
message = f"{type(item).__name__}: {item}"
search_errors.append(message)
intranet_debug["queries"].append({"query": "(unknown)", "count": 0, "error": message})
continue
kw, results, error, elapsed_ms = item
row = {"query": kw, "count": len(results or []), "elapsed_ms": elapsed_ms}
if error:
row["error"] = error
search_errors.append(error)
intranet_debug["queries"].append(row)
new_results.extend(results or [])
search_errors.extend(getattr(provider, "errors", []))
provider_errors = _unique_limited(getattr(provider, "errors", []) or [], 5)
search_errors.extend(provider_errors)
intranet_debug["errors"] = provider_errors
intranet_debug["raw_responses"] = _unique_limited(getattr(provider, "raw_responses", []) or [], 3)
intranet_debug["calls"] = list(getattr(provider, "calls", []) or [])[:20]
intranet_debug["result_count"] = len(new_results)
if search_errors and new_results:
intranet_debug["status"] = "partial"
elif search_errors:
intranet_debug["status"] = "error"
elif new_results:
intranet_debug["status"] = "ok"
else:
intranet_debug["status"] = "empty"
query_summary = _intranet_query_summary(intranet_debug["queries"])
if search_errors:
uniq_errors = _unique_limited(search_errors, 3)
_emit_skill_step({
"type": "skill_error",
"message": f"「{_KNOWLEDGE_SKILL_DISPLAY}」内网接口调用异常:{';'.join(uniq_errors)}",
"skill_name": KNOWLEDGE_SEARCH_SKILL_NAME,
"reason": ";".join(uniq_errors),
})
result_message = (
f"「{_KNOWLEDGE_SKILL_DISPLAY}」(内网接口)调用完成,检索到 {len(new_results)} 条素材"
+ (f"({query_summary})" if query_summary else "")
)
_emit_skill_step({
"type": "skill_result",
"message": f"「{_KNOWLEDGE_SKILL_DISPLAY}」(内网接口)检索到 {len(new_results)} 条素材",
"message": result_message,
"skill_name": KNOWLEDGE_SEARCH_SKILL_NAME,
"matched_count": len(new_results),
})
@@ -758,7 +847,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
)
return (results or []), ""
except asyncio.TimeoutError:
except TimeoutError:
msg = f"检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)"
logger.warning("skill[%s] search timed out after %ss", skill_name, _SKILL_SEARCH_TIMEOUT_SECONDS)
search_errors.append(f"skill[{skill_name}]: {msg}")
@@ -901,16 +990,18 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
intranet_url = (ai_writing_config.get("intranet_search_url") or "").strip()
# 诊断开关:开启后即使技能成功也照样跑完内网并记录完整结果(用于验证内网接口是否真能通)。
intranet_debug_force = bool(ai_writing_config.get("intranet_search_debug", False))
intranet_task: "asyncio.Task | None" = None
intranet_task: asyncio.Task | None = None
intranet_holder: dict = {}
intranet_debug = {
"configured": bool(intranet_url),
"endpoint": intranet_url or "(未配置 ai_writing.intranet_search_url)",
"knowledge_base": ai_writing_config.get("intranet_knowledge_base", "samples"),
"timeout": _intranet_timeout_seconds(ai_writing_config),
"verify_ssl": bool(ai_writing_config.get("intranet_verify_ssl", False)),
"debug_force": intranet_debug_force,
"status": "pending" if intranet_url else "not_configured",
"queries": [], # 每个检索词:{query, count, error?}
"calls": [], # provider 每次真实 HTTP 调用,含简化词重试
"errors": [], # provider 聚合的报错
"raw_responses": [], # 解析失败时的原始响应片段
"result_count": 0, # 内网总命中条数
@@ -929,6 +1020,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 兜底接口超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,
# 默认 600s(10 分钟),且最低 300s。
timeout=_intranet_timeout_seconds(ai_writing_config),
verify_ssl=ai_writing_config.get("intranet_verify_ssl", False),
)
intranet_holder["provider"] = intranet
fb_keywords = keywords[:keyword_count] or [intent]
@@ -945,8 +1037,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
continue
intranet_debug["queries"].append({"query": kw, "count": len(res or [])})
collected.extend(res or [])
intranet_debug["errors"] = list(dict.fromkeys(getattr(intranet, "errors", [])))[:5]
intranet_debug["raw_responses"] = list(dict.fromkeys(getattr(intranet, "raw_responses", [])))[:3]
intranet_debug["errors"] = _unique_limited(getattr(intranet, "errors", []) or [], 5)
intranet_debug["raw_responses"] = _unique_limited(getattr(intranet, "raw_responses", []) or [], 3)
intranet_debug["calls"] = list(getattr(intranet, "calls", []) or [])[:20]
intranet_debug["result_count"] = len(collected)
intranet_debug["status"] = "ran"
return collected
@@ -1,8 +1,8 @@
from .base import BaseSearchProvider, SearchResult
from .internet_search import InternetSearchProvider
from .intranet_stub import IntranetSearchProvider, normalize_intranet_timeout
from .demo_search import DemoSearchProvider
from .duckduckgo_search import DuckDuckGoSearchProvider
from .internet_search import InternetSearchProvider
from .intranet_stub import IntranetSearchProvider, normalize_intranet_timeout, normalize_intranet_verify_ssl
from .skill_search import SkillSearchProvider
@@ -14,6 +14,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: samples
intranet_search_timeout: 300 # 内网检索超时秒数,最少 300 秒
intranet_verify_ssl: false # 内网 HTTPS 自签名证书场景默认关闭证书校验
ddg_region: "cn-zh" # DuckDuckGo 区域,默认 cn-zh
internet 模式:DuckDuckGo 为主搜索(免 API Key),失败时 fallback 到 demo 演示素材。
@@ -26,6 +27,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
base_url=cfg.get("intranet_search_url", ""),
knowledge_base_name=cfg.get("intranet_knowledge_base", "samples"),
timeout=normalize_intranet_timeout(cfg.get("intranet_search_timeout")),
verify_ssl=normalize_intranet_verify_ssl(cfg.get("intranet_verify_ssl")),
)
if provider == "tavily":
return _FallbackSearchProvider(InternetSearchProvider(), DemoSearchProvider())
@@ -2,7 +2,7 @@
from __future__ import annotations
import logging
from typing import List
import time
from .base import BaseSearchProvider, SearchResult, simplify_query
@@ -14,6 +14,8 @@ _DEFAULT_KB = "samples"
# 内网检索最少等待 5 分钟;该接口经常慢返回,低于这个值会造成素材收集误判为空。
_MIN_TIMEOUT_SECONDS = 300
_DEFAULT_TIMEOUT_SECONDS = 600
# 内网 HTTPS 检索服务常使用自签名/内部 CA 证书,默认跳过证书校验避免 SSL 证书链错误。
_DEFAULT_VERIFY_SSL = False
# 单条原始响应体最大保留长度,避免把超大响应整段灌进前端时间线
_RAW_SNIPPET_LIMIT = 2000
@@ -27,6 +29,23 @@ def normalize_intranet_timeout(timeout: object, default: int = _DEFAULT_TIMEOUT_
return max(value, _MIN_TIMEOUT_SECONDS)
def normalize_intranet_verify_ssl(value: object, default: bool = _DEFAULT_VERIFY_SSL) -> bool:
"""返回内网检索是否校验 SSL 证书,兼容 config.yaml 里的 bool / 字符串写法。"""
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return default
def _pick(d: dict, keys: tuple) -> str:
"""从结果项里按候选字段名取第一个非空值(两套接口字段名不同,做并集兼容)。"""
for k in keys:
@@ -38,6 +57,16 @@ def _pick(d: dict, keys: tuple) -> str:
return ""
def _raw_snippet(body: str) -> str:
"""Return a bounded raw response snippet for diagnostics."""
body = (body or "").strip()
if not body:
return ""
if len(body) > _RAW_SNIPPET_LIMIT:
return body[:_RAW_SNIPPET_LIMIT] + "…(响应体过长,已截断)"
return body
class IntranetSearchProvider(BaseSearchProvider):
"""内网知识库搜索接口适配器。
@@ -51,27 +80,29 @@ class IntranetSearchProvider(BaseSearchProvider):
base_url: str = "",
knowledge_base_name: str = _DEFAULT_KB,
timeout: int = _DEFAULT_TIMEOUT_SECONDS,
verify_ssl: bool = _DEFAULT_VERIFY_SSL,
**_ignored,
):
self.endpoint = base_url or _DEFAULT_ENDPOINT
self.knowledge_base_name = knowledge_base_name or _DEFAULT_KB
# 内网 ES 接口很慢,兜底检索超时默认 10 分钟,且无论配置如何至少 5 分钟。
self.timeout = normalize_intranet_timeout(timeout)
self.verify_ssl = normalize_intranet_verify_ssl(verify_ssl)
# 收集本次运行的检索异常,供 researcher 节点取出并提示用户(ES 波动等)
self.errors: list[str] = []
# 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示
self.raw_responses: list[str] = []
# 每一次真实 HTTP 调用的诊断明细。search() 可能会因 0 结果触发简化词重试,
# 因此一个业务关键词可能对应多条 calls。
self.calls: list[dict] = []
def _record_raw(self, body: str) -> None:
"""收集解析失败 / 结构异常时的原始响应体,供前端折叠查看。"""
body = (body or "").strip()
if not body:
return
if len(body) > _RAW_SNIPPET_LIMIT:
body = body[:_RAW_SNIPPET_LIMIT] + "…(响应体过长,已截断)"
self.raw_responses.append(body)
snippet = _raw_snippet(body)
if snippet:
self.raw_responses.append(snippet)
async def search(self, query: str, max_results: int = 5, **kwargs) -> List[SearchResult]:
async def search(self, query: str, max_results: int = 5, **kwargs) -> list[SearchResult]:
results = await self._search_once(query, max_results)
# 0 结果兜底:复合检索词常检不出(如「特朗普竞选」),换成更核心的检索词重试一次
# (「特朗普竞选」→「特朗普」)。仅在首检为空、且能简化出不同的词时才重试。
@@ -82,7 +113,7 @@ class IntranetSearchProvider(BaseSearchProvider):
results = await self._search_once(simpler, max_results)
return results
async def _search_once(self, query: str, max_results: int = 5) -> List[SearchResult]:
async def _search_once(self, query: str, max_results: int = 5) -> list[SearchResult]:
import httpx
payload = {
@@ -92,44 +123,116 @@ class IntranetSearchProvider(BaseSearchProvider):
}
# 先拿到原始响应文本,JSON 解析失败时把它原样回传前端便于定位接口返回了什么
raw_text = ""
started = time.monotonic()
call: dict = {
"query": query,
"endpoint": self.endpoint,
"knowledge_base_name": self.knowledge_base_name,
"max_results": max_results,
"status": "pending",
"ok": False,
"count": 0,
"verify_ssl": self.verify_ssl,
}
# 排查用:每次真实调用都打一行请求明细(端点 / 知识库 / 检索词 / 超时),
# 内网复现时直接看后端日志即可知道"到底请求了哪个地址、用什么参数"。
logger.info(
"[intranet-search] POST endpoint=%s kb=%s timeout=%ss verify_ssl=%s query=%r",
self.endpoint, self.knowledge_base_name, self.timeout, self.verify_ssl, query,
)
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
# trust_env=False:**关键**。内网 ES 是内网地址,但本部署的模型走公网,
# 服务器通常设了 HTTP_PROXY/HTTPS_PROXY 出公网;httpx 默认 trust_env=True
# 会把这个内网请求也套用公网代理 → 代理到不了内网 IP → 连接被重置/空回复,
# 表现为「接口异常」。强制忽略代理 env、直连内网(与 multi_agent.py 的回环
# 调用同款修法)。
# verify=False:内网 HTTPS 常见自签名/内部 CA 证书;这里仅跳过本内网检索端点的证书校验。
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False, verify=self.verify_ssl) as client:
resp = await client.post(self.endpoint, json=payload)
call["http_status"] = getattr(resp, "status_code", None)
resp.raise_for_status()
raw_text = resp.text
data = resp.json()
except httpx.HTTPStatusError as e:
# 非 2xx:接口有响应但状态码异常,把响应体一并暴露给前端
logger.warning("intranet search failed for query=%r: %s", query, e)
self.errors.append(f"{type(e).__name__}: {e}")
self._record_raw(e.response.text if e.response is not None else "")
body_for_log = e.response.text if e.response is not None else ""
logger.warning(
"[intranet-search] HTTP error endpoint=%s query=%r status=%s body=%s",
self.endpoint, query, getattr(e.response, "status_code", None), _raw_snippet(body_for_log),
)
message = f"{type(e).__name__}: {e}"
self.errors.append(message)
call.update({
"status": "http_error",
"error": message,
"http_status": getattr(e.response, "status_code", None),
})
snippet = _raw_snippet(body_for_log)
if snippet:
call["raw_response"] = snippet
self._record_raw(body_for_log)
return []
except Exception as e:
# 连接失败 / 超时 / JSON 解析失败等;JSON 解析失败时 raw_text 已是接口响应体
logger.warning("intranet search failed for query=%r: %s", query, e)
self.errors.append(f"{type(e).__name__}: {e}")
# 连接失败 / 超时 / JSON 解析失败等;JSON 解析失败时 raw_text 已是接口响应体。
# 打出异常类型,便于区分 ConnectError / ConnectTimeout / ReadTimeout /
# RemoteProtocolError(空回复) / JSONDecodeError(返回非 JSON) 等具体根因。
logger.warning(
"[intranet-search] request failed endpoint=%s query=%r error=%s: %s",
self.endpoint, query, type(e).__name__, e,
)
message = f"{type(e).__name__}: {e}"
self.errors.append(message)
call.update({"status": "error", "error": message})
snippet = _raw_snippet(raw_text)
if snippet:
call["raw_response"] = snippet
self._record_raw(raw_text)
return []
finally:
call["elapsed_ms"] = int((time.monotonic() - started) * 1000)
# 非 pending 表示已经在异常分支写好;成功分支会在 return 前覆盖并追加。
if call.get("status") != "pending":
self.calls.append(call)
# 正常情况下接口直接返回数组;兼容包了一层 data/results 的结构(含工作正常的
# knowledge-search-v2:{"success":true,"results":[...]})。
if isinstance(data, list):
items = data
elif isinstance(data, dict):
if "success" in data:
call["response_success"] = bool(data.get("success"))
if "count" in data:
call["response_count"] = data.get("count")
# 接口显式标失败时把整体回传前端便于定位
if data.get("success") is False:
self.errors.append(f"接口返回 success=false:{str(data.get('error') or '')[:200]}")
message = f"接口返回 success=false:{str(data.get('error') or '')[:200]}"
self.errors.append(message)
call.update({"status": "success_false", "error": message})
snippet = _raw_snippet(raw_text)
if snippet:
call["raw_response"] = snippet
self.calls.append(call)
self._record_raw(raw_text)
return []
items = data.get("data") or data.get("results") or data.get("documents") or []
else:
# 返回结构既不是数组也不是已知 dict 包装:视为异常,把原始响应回传前端
logger.warning("intranet search unexpected response type for query=%r: %s", query, type(data).__name__)
self.errors.append(f"返回结构异常:期望数组,实际为 {type(data).__name__}")
message = f"返回结构异常:期望数组,实际为 {type(data).__name__}"
self.errors.append(message)
call.update({"status": "bad_shape", "error": message})
snippet = _raw_snippet(raw_text)
if snippet:
call["raw_response"] = snippet
self.calls.append(call)
self._record_raw(raw_text)
return []
results: List[SearchResult] = []
logger.info(
"[intranet-search] OK endpoint=%s query=%r http_status=%s raw_items=%s",
self.endpoint, query, call.get("http_status"), len(items) if isinstance(items, list) else 0,
)
results: list[SearchResult] = []
for i, item in enumerate(items[:max_results]):
if not isinstance(item, dict):
continue
@@ -158,6 +261,17 @@ class IntranetSearchProvider(BaseSearchProvider):
source_type="intranet",
recUuid=_pick(merged, ("recUuid", "rec_uuid", "id")),
))
call.update({
"status": "ok" if results else "empty",
"ok": True,
"count": len(results),
"item_count": len(items) if isinstance(items, list) else 0,
})
if not results:
snippet = _raw_snippet(raw_text)
if snippet:
call["raw_response"] = snippet
self.calls.append(call)
return results
async def health_check(self) -> bool:
@@ -40,6 +40,9 @@ class MaterialPackage(TypedDict, total=False):
skill_search_used: bool
# 技能检索的步骤记录(前端展示"使用了 X 技能"卡片)
skill_search_steps: List[SkillStep]
# 内网知识库接口诊断:端点、每个检索词的命中数/耗时/错误、原始响应片段等。
# 用于排查 "检索很快返回 0 条" 时到底是否真正调用了内网接口。
intranet_debug: dict
# ── 大纲 ──────────────────────────────────────────────────────────────────────
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
@@ -12,9 +12,10 @@ from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMid
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.setup_writing_approval_middleware import SetupWritingApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.task_external_context_middleware import TaskExternalContextMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddleware
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
@@ -326,12 +327,16 @@ def _build_middlewares(
# to the global ``knowledge.rag_enabled`` config. No-op when knowledge is
# disabled. Skipped in bootstrap/writing-setup/ephemeral like MemoryMiddleware.
knowledge_cfg = getattr(resolved_app_config, "knowledge", None)
if not is_bootstrap and not is_writing_setup and not is_ephemeral and knowledge_cfg is not None and knowledge_cfg.enabled:
from deerflow.agents.middlewares.knowledge_rag_middleware import KnowledgeRagMiddleware
middlewares.append(KnowledgeRagMiddleware(limit=knowledge_cfg.rag_limit, default_enabled=knowledge_cfg.rag_enabled))
# Add ViewImageMiddleware only for the NATIVE-vision path (main model
if not is_bootstrap and not is_writing_setup and not is_ephemeral and knowledge_cfg is not None and knowledge_cfg.enabled:
from deerflow.agents.middlewares.knowledge_rag_middleware import KnowledgeRagMiddleware
middlewares.append(KnowledgeRagMiddleware(limit=knowledge_cfg.rag_limit, default_enabled=knowledge_cfg.rag_enabled))
# TaskExternalContextMiddleware — inject hidden per-run business context
# (for example rwfx's completed 3q report) into the model request only.
middlewares.append(TaskExternalContextMiddleware())
# Add ViewImageMiddleware only for the NATIVE-vision path (main model
# ingests raw image data). In delegated mode the view_image tool already
# returns a text description and stores nothing in `viewed_images`, so the
# middleware would otherwise inject a spurious "No images have been viewed."
@@ -0,0 +1,88 @@
"""Inject task-specific external context into the model request.
The web task workspace can pass business data through LangGraph run context
without showing it in the chat transcript. This middleware converts that
context into a transient message right before model invocation, so it is
available to the agent but never persisted as user-visible conversation state.
"""
from __future__ import annotations
import json
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import HumanMessage
_CONTEXT_KEY = "task_external_context"
_TAG = "task_external_context"
_MAX_CONTEXT_CHARS = 18000
_TAIL_CONTEXT_CHARS = 2500
def _truncate_middle(text: str, max_chars: int = _MAX_CONTEXT_CHARS) -> str:
if len(text) <= max_chars:
return text
marker = "\n\n...[隐藏任务上下文过长,已截断中间部分]...\n\n"
tail_chars = min(_TAIL_CONTEXT_CHARS, max_chars // 4)
head_chars = max(0, max_chars - len(marker) - tail_chars)
return f"{text[:head_chars]}{marker}{text[-tail_chars:]}"
def _render_context(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
try:
text = json.dumps(value, ensure_ascii=False, indent=2, default=str).strip()
except Exception:
text = str(value).strip()
return _truncate_middle(text)
def _task_context_from_request(request: Any) -> Any:
runtime = getattr(request, "runtime", None)
context = getattr(runtime, "context", None)
if isinstance(context, dict):
return context.get(_CONTEXT_KEY)
return None
def _build_context_message(value: Any) -> HumanMessage | None:
text = _render_context(value)
if not text:
return None
content = (
f"<{_TAG}>\n"
"[系统提示:以下是业务系统随本次运行提供的隐藏任务上下文,不是用户的新输入。"
"回答当前任务时应作为背景资料参考;不要主动说明这些内容来自隐藏上下文,"
"也不要在回答中暴露本标签本身。]\n\n"
f"{text}\n"
f"</{_TAG}>"
)
return HumanMessage(
content=content,
additional_kwargs={"hide_from_ui": True, _CONTEXT_KEY: True},
)
class TaskExternalContextMiddleware(AgentMiddleware[AgentState]):
"""Append hidden task context as a transient model-call message."""
state_schema = AgentState
@override
async def awrap_model_call(self, request: Any, handler: Any) -> Any:
msg = _build_context_message(_task_context_from_request(request))
if msg is not None:
request = request.override(messages=[*request.messages, msg])
return await handler(request)
@override
def wrap_model_call(self, request: Any, handler: Any) -> Any:
msg = _build_context_message(_task_context_from_request(request))
if msg is not None:
request = request.override(messages=[*request.messages, msg])
return handler(request)
@@ -1,21 +1,49 @@
"""Middleware for automatic thread title generation."""
import logging
import re
from typing import TYPE_CHECKING, Any, NotRequired, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langgraph.runtime import Runtime
from deerflow.config.title_config import get_title_config
from deerflow.models import create_chat_model
"""Middleware for automatic thread title generation."""
import copy
import logging
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any, NotRequired, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langgraph.runtime import Runtime
from deerflow.config.title_config import get_title_config
from deerflow.models import create_chat_model
from deerflow.utils.time import now_iso
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
from deerflow.config.title_config import TitleConfig
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
_TITLE_DEBUG_LOGGER_NAME = "title_generation_debug"
def _get_title_debug_logger() -> logging.Logger:
debug_logger = logging.getLogger(_TITLE_DEBUG_LOGGER_NAME)
if getattr(debug_logger, "_title_file_configured", False):
return debug_logger
log_path = Path("logs/title_generation_debug.log")
if not log_path.is_absolute():
log_path = Path.cwd() / log_path
log_path.parent.mkdir(parents=True, exist_ok=True)
handler = logging.FileHandler(log_path, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
debug_logger.addHandler(handler)
debug_logger.setLevel(logging.INFO)
debug_logger.propagate = False
setattr(debug_logger, "_title_file_configured", True)
return debug_logger
def log_title_debug(message: str, *args: Any) -> None:
logger.warning(message, *args)
_get_title_debug_logger().info(message, *args)
class TitleMiddlewareState(AgentState):
@@ -25,7 +53,7 @@ class TitleMiddlewareState(AgentState):
title_provisional: NotRequired[bool]
class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
"""Automatically generate a title for the thread after the first user message."""
state_schema = TitleMiddlewareState
@@ -42,9 +70,9 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
return self._app_config.title
return get_title_config()
def _normalize_content(self, content: object) -> str:
if isinstance(content, str):
return content
def _normalize_content(self, content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [self._normalize_content(item) for item in content]
@@ -58,8 +86,18 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
nested_content = content.get("content")
if nested_content is not None:
return self._normalize_content(nested_content)
return ""
return ""
def _message_type(self, message: object) -> str:
if isinstance(message, dict):
return str(message.get("type") or message.get("role") or "").lower()
return str(getattr(message, "type", "") or getattr(message, "role", "") or "").lower()
def _message_content(self, message: object) -> object:
if isinstance(message, dict):
return message.get("content", "")
return getattr(message, "content", "")
def _should_set_provisional_title(self, state: TitleMiddlewareState) -> bool:
"""Check if we should set an instant, question-derived placeholder title.
@@ -78,8 +116,8 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
# Only on the very first turn, as soon as the user message arrives.
messages = state.get("messages", [])
user_messages = [m for m in messages if m.type == "human"]
return len(user_messages) == 1
user_messages = [m for m in messages if self._message_type(m) in {"human", "user"}]
return len(user_messages) == 1
def _should_generate_title(self, state: TitleMiddlewareState) -> bool:
"""Check if we should generate the final LLM title for this thread."""
@@ -98,8 +136,8 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
return False
# Count user and assistant messages
user_messages = [m for m in messages if m.type == "human"]
assistant_messages = [m for m in messages if m.type == "ai"]
user_messages = [m for m in messages if self._message_type(m) in {"human", "user"}]
assistant_messages = [m for m in messages if self._message_type(m) in {"ai", "assistant"}]
# Generate title after first complete exchange
return len(user_messages) == 1 and len(assistant_messages) >= 1
@@ -112,8 +150,8 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
config = self._get_title_config()
messages = state.get("messages", [])
user_msg_content = next((m.content for m in messages if m.type == "human"), "")
assistant_msg_content = next((m.content for m in messages if m.type == "ai"), "")
user_msg_content = next((self._message_content(m) for m in messages if self._message_type(m) in {"human", "user"}), "")
assistant_msg_content = next((self._message_content(m) for m in messages if self._message_type(m) in {"ai", "assistant"}), "")
user_msg = self._normalize_content(user_msg_content)
assistant_msg = self._strip_think_tags(self._normalize_content(assistant_msg_content))
@@ -174,7 +212,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
return None
messages = state.get("messages", [])
user_msg_content = next((m.content for m in messages if m.type == "human"), "")
user_msg_content = next((self._message_content(m) for m in messages if self._message_type(m) in {"human", "user"}), "")
user_msg = self._normalize_content(user_msg_content)
if not user_msg.strip():
return None
@@ -261,10 +299,98 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
async def abefore_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
return self._provisional_title_result(state)
@override
def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
return self._generate_title_result(state)
@override
async def aafter_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
return await self._agenerate_title_result(state)
@override
def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
# Keep the provisional title in place during the main run. The gateway
# worker finalizes it in a background task after the answer is complete.
return None
@override
async def aafter_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
# Keep the provisional title in place during the main async run so the
# worker's post-run background finalizer can replace it with the real
# LLM-generated title without blocking the input box.
return None
async def finalize_thread_title(
*,
checkpointer: Any,
thread_store: Any | None,
thread_id: str,
source_state: dict[str, Any],
app_config: "AppConfig | None" = None,
) -> bool:
"""Finalize a provisional thread title in the background.
``source_state`` must be the checkpoint snapshot captured when the first run
completed. That avoids races where the user asks a second question before
the background title task starts, which would otherwise make the current
checkpoint fail the "first exchange only" predicate and leave the title
provisional forever.
"""
log_title_debug("Title finalize: evaluating background title generation for thread %s", thread_id)
middleware = TitleMiddleware(app_config=app_config)
source_snapshot = copy.deepcopy(source_state)
if not middleware._should_generate_title(source_snapshot):
log_title_debug(
"Title finalize: skipped for thread %s because source snapshot is not eligible",
thread_id,
)
return False
log_title_debug("Title finalize: starting background title generation for thread %s", thread_id)
result = await middleware._agenerate_title_result(source_snapshot)
if not result:
log_title_debug("Title finalize: no title result produced for thread %s", thread_id)
return False
read_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
checkpoint_tuple = await checkpointer.aget_tuple(read_config)
if checkpoint_tuple is None:
log_title_debug("Title finalize: skipped for thread %s because checkpoint was not found", thread_id)
return False
checkpoint: dict[str, Any] = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {})
metadata: dict[str, Any] = dict(getattr(checkpoint_tuple, "metadata", {}) or {})
channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {}) or {})
# If another path already finalized or manually renamed the title, keep it.
if channel_values.get("title_provisional") is not True:
log_title_debug(
"Title finalize: skipped for thread %s because title is no longer provisional",
thread_id,
)
return False
channel_values.update(result)
checkpoint["channel_values"] = channel_values
metadata["updated_at"] = now_iso()
write_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
new_versions: dict[str, Any] = {}
get_next_version = getattr(checkpointer, "get_next_version", None)
if callable(get_next_version):
versions = dict(checkpoint.get("channel_versions", {}) or {})
for key in ("title", "title_provisional"):
next_version = get_next_version(versions.get(key), None)
versions[key] = next_version
new_versions[key] = next_version
checkpoint["channel_versions"] = versions
await checkpointer.aput(write_config, checkpoint, metadata, new_versions)
title = result.get("title")
if title and thread_store is not None:
await thread_store.update_display_name(thread_id, title, user_id=None)
log_title_debug("Title finalize: completed for thread %s with title %r", thread_id, title)
return True
@@ -18,6 +18,7 @@ import logging
import threading
from pathlib import Path
from typing import Literal
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field
@@ -38,6 +39,60 @@ class PromptPrefixSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class AppearanceRouteParam(BaseModel):
"""Runtime query-param descriptor for top-bar shortcut buttons.
The frontend resolves login params from ``userInfo`` and theme params from
the current UI theme, matching the light-app center's link builder.
"""
key: str = Field(default="", description="Query parameter name appended to the shortcut URL")
paramType: Literal["login", "theme"] = Field(default="login", description="Where the parameter value comes from")
source: str | None = Field(default=None, description="login param source field in userInfo, or 'other'")
customValue: str | None = Field(default=None, description="Literal value when source is 'other'")
themeValue: str | None = Field(default=None, description="Theme-value mapping string, e.g. red=red;dark-blue=dark")
model_config = ConfigDict(extra="ignore")
def _default_shortcut_route_params() -> list[AppearanceRouteParam]:
return [
AppearanceRouteParam(
key="access_token",
paramType="login",
source="access_token",
customValue="",
themeValue="red=red;blue=blue;dark-blue=dark-blue",
)
]
class CompactShortcutButton(BaseModel):
"""Configurable shortcut shown in top bars before the theme switcher."""
id: str = Field(default_factory=lambda: uuid4().hex, description="Stable row id for editing")
label: str = Field(default="快捷入口", description="Button text shown next to the icon")
url: str = Field(default="", description="Target URL opened in a new tab")
route_params: list[AppearanceRouteParam] = Field(
default_factory=_default_shortcut_route_params,
description="Query parameters resolved at launch time and appended to url",
)
enabled: bool = Field(default=True, description="Whether this shortcut is rendered")
sort_order: int = Field(default=0, description="Display order in top bars")
model_config = ConfigDict(extra="ignore")
def _default_compact_shortcut_buttons() -> list[CompactShortcutButton]:
return [
CompactShortcutButton(
id="default-compact-shortcut",
label="快捷入口",
url="",
enabled=True,
sort_order=0,
)
]
class AppearanceSettings(BaseModel):
"""System-wide appearance defaults that any *new* user inherits.
@@ -63,6 +118,10 @@ class AppearanceSettings(BaseModel):
default="full",
description="Default interface layout for users who haven't picked their own — full (sidebar + header) / compact (hidden)",
)
compact_shortcut_buttons: list[CompactShortcutButton] = Field(
default_factory=_default_compact_shortcut_buttons,
description="Shortcut buttons rendered before the theme switcher in top bars",
)
model_config = ConfigDict(extra="ignore")
@@ -206,6 +265,26 @@ class AgentModuleSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class PluginDomainAgentMapping(BaseModel):
"""One configured host -> agent mapping for iframe chat auto-selection."""
id: str = Field(..., description="Stable row id for client-side editing")
domain: str = Field(..., description="Normalized host matched against pageContext.url")
agent_id: str = Field(..., description="Agent id selected when the host matches")
enabled: bool = Field(default=True, description="Whether this mapping is active")
model_config = ConfigDict(extra="ignore")
class PluginManagementSettings(BaseModel):
"""Admin-configurable iframe plugin mappings."""
domain_agent_mappings: list[PluginDomainAgentMapping] = Field(
default_factory=list,
description="Ordered host -> agent mappings used by the iframe chat entry page.",
)
model_config = ConfigDict(extra="ignore")
def get_module_tag_ids(settings: AgentModuleSettings, key: str) -> list[str]:
"""Return the configured agent-tag ids for ``key`` (empty when unset)."""
return list(settings.modules.get(key, []))
@@ -249,6 +328,26 @@ class ConcurrencyMonitorSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class ModelConcurrencySettings(BaseModel):
"""Admin-configurable per-model concurrent Q&A limits."""
enabled: bool = Field(
default=False,
description="Whether per-model concurrency limiting is enabled.",
)
default_limit: int = Field(
default=10,
ge=1,
le=10_000,
description="Fallback max concurrent runs for models without an explicit override.",
)
model_limits: dict[str, int] = Field(
default_factory=dict,
description="Optional per-model overrides: model_name -> max concurrent runs.",
)
model_config = ConfigDict(extra="ignore")
class SystemSettings(BaseModel):
"""Top-level system settings document."""
@@ -260,8 +359,10 @@ class SystemSettings(BaseModel):
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings)
plugin_management: PluginManagementSettings = Field(default_factory=PluginManagementSettings)
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)
model_config = ConfigDict(extra="ignore")
@@ -305,6 +406,15 @@ def get_concurrency_monitor_settings() -> ConcurrencyMonitorSettings:
return ConcurrencyMonitorSettings()
def get_model_concurrency_settings() -> ModelConcurrencySettings:
"""Convenience accessor for the model-concurrency block (safe defaults on error)."""
try:
return load_system_settings().model_concurrency
except Exception:
logger.debug("Failed to read model concurrency settings; using defaults", exc_info=True)
return ModelConcurrencySettings()
def save_system_settings(settings: SystemSettings) -> SystemSettings:
"""Persist the settings document atomically, returning the saved value."""
path = _settings_path()
@@ -6,20 +6,21 @@ from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import and_, case, delete, func, or_, select
from sqlalchemy import and_, case, delete, func, literal, not_, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.config.agent_name_desensitize import source_terms_for_needle
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentPinRow,
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.tags.model import TagAssignmentRow
from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentPinRow,
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.tags.model import TagAssignmentRow
def _row_to_dict(row: AgentRow) -> dict[str, Any]:
@@ -32,9 +33,33 @@ def _row_to_dict(row: AgentRow) -> dict[str, Any]:
return data
class AgentRepository(AgentStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
class AgentRepository(AgentStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _business_chain_agent_exists(agent_id_col):
return (
select(RoundtableChainRow.id)
.where(
or_(
RoundtableChainRow.seats.like(literal('%"agent_id": "') + agent_id_col + literal('"%')),
RoundtableChainRow.seats.like(literal('%"agent_id":"') + agent_id_col + literal('"%')),
)
)
.exists()
)
@classmethod
def _valid_favorite_filter(cls):
return or_(
AgentFavoriteRow.origin == "user",
and_(
AgentFavoriteRow.origin == "position",
AgentRow.user_id.is_not(None),
not_(cls._business_chain_agent_exists(AgentFavoriteRow.agent_id)),
),
)
@staticmethod
def _visible_filter(user_id: str):
@@ -52,15 +77,23 @@ class AgentRepository(AgentStore):
``published=false``, so this clause cannot lean on ``published``;
- favorited — the "我的" listing keeps favorited agents even after
the owner unpublishes them, so reads keep honoring the favorite.
"""
fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
return or_(
AgentRow.user_id.is_(None),
AgentRow.user_id == user_id,
AgentRow.published.is_(True),
AgentRow.is_private.is_(True),
AgentRow.id.in_(fav_subq),
)
"""
valid_favorite_exists = (
select(AgentFavoriteRow.id)
.where(
AgentFavoriteRow.user_id == user_id,
AgentFavoriteRow.agent_id == AgentRow.id,
AgentRepository._valid_favorite_filter(),
)
.exists()
)
return or_(
AgentRow.user_id.is_(None),
AgentRow.user_id == user_id,
AgentRow.published.is_(True),
AgentRow.is_private.is_(True),
valid_favorite_exists,
)
@staticmethod
def _listing_filter(user_id: str):
@@ -268,11 +301,20 @@ class AgentRepository(AgentStore):
or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)),
AgentRow.is_private.is_(False),
)
if scope == "mine":
# owned OR favorited. A correlated IN-subquery (not a JOIN) keeps a
# single row per agent even when it is both owned and favorited.
fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq))
if scope == "mine":
# owned OR valid favorites. Built-in agents and business-chain
# seats only enter "mine" when explicitly favorited by the user;
# the original position-granted behavior stays for ordinary agents.
valid_favorite_exists = (
select(AgentFavoriteRow.id)
.where(
AgentFavoriteRow.user_id == user_id,
AgentFavoriteRow.agent_id == AgentRow.id,
self._valid_favorite_filter(),
)
.exists()
)
return or_(AgentRow.user_id == user_id, valid_favorite_exists)
if scope == "private":
return AgentRow.is_private.is_(True)
if scope == "builtin":
@@ -437,18 +479,22 @@ class AgentRepository(AgentStore):
# --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None:
async with self._sf() as session:
existing = (
await session.execute(
select(AgentFavoriteRow.id)
.where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id)
.limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
async def add_favorite(self, user_id: str, agent_id: str) -> None:
async with self._sf() as session:
existing = (
await session.execute(
select(AgentFavoriteRow)
.where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id)
.limit(1)
)
).scalar_one_or_none()
if existing is not None:
if existing.origin != "user" or existing.position_id is not None:
existing.origin = "user"
existing.position_id = None
await session.commit()
return
session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
try:
await session.commit()
except IntegrityError:
@@ -467,12 +513,17 @@ class AgentRepository(AgentStore):
await session.commit()
return (result.rowcount or 0) > 0
async def list_favorite_ids(self, user_id: str) -> set[str]:
async with self._sf() as session:
result = await session.execute(
select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
)
return {row[0] for row in result.all()}
async def list_favorite_ids(self, user_id: str) -> set[str]:
async with self._sf() as session:
result = await session.execute(
select(AgentFavoriteRow.agent_id)
.join(AgentRow, AgentRow.id == AgentFavoriteRow.agent_id)
.where(
AgentFavoriteRow.user_id == user_id,
self._valid_favorite_filter(),
)
)
return {row[0] for row in result.all()}
async def delete_favorites_for_agent(self, agent_id: str) -> None:
async with self._sf() as session:
@@ -0,0 +1,90 @@
"""Stop position-granted favorites from collecting built-ins/chain seats.
Built-in agents and agents used as business-chain seats should only enter
"mine" when a user explicitly clicks the favorite action. This migration
removes historical automatic ``origin='position'`` rows for those agents while
preserving user-created favorites (``origin='user'``) and ordinary
position-granted agent favorites.
Revision ID: 20260701_01
Revises: 20260625_04
Create Date: 2026-07-01
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260701_01"
down_revision = "20260625_04"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if not (
_has_table("agent_favorites")
and _has_column("agent_favorites", "origin")
and _has_column("agent_favorites", "agent_id")
):
return
favorites = sa.table(
"agent_favorites",
sa.column("agent_id", sa.String(64)),
sa.column("origin", sa.String(16)),
)
cleanup_conditions = []
if _has_table("agents") and _has_column("agents", "user_id"):
agents = sa.table(
"agents",
sa.column("id", sa.String(64)),
sa.column("user_id", sa.String(64)),
)
cleanup_conditions.append(
favorites.c.agent_id.in_(sa.select(agents.c.id).where(agents.c.user_id.is_(None)))
)
if _has_table("roundtable_chains") and _has_column("roundtable_chains", "seats"):
chains = sa.table(
"roundtable_chains",
sa.column("seats", sa.Text()),
)
cleanup_conditions.append(
sa.select(sa.literal(1))
.select_from(chains)
.where(
sa.or_(
chains.c.seats.like(sa.literal('%"agent_id": "') + favorites.c.agent_id + sa.literal('"%')),
chains.c.seats.like(sa.literal('%"agent_id":"') + favorites.c.agent_id + sa.literal('"%')),
)
)
.exists()
)
if not cleanup_conditions:
return
op.get_bind().execute(
favorites.delete().where(
favorites.c.origin == "position",
sa.or_(*cleanup_conditions),
)
)
def downgrade() -> None:
# Data cleanup only; removed auto-grants are not reconstructable.
pass
@@ -1,46 +1,57 @@
"""LangGraph-compatible runtime — runs, streaming, and lifecycle management.
Re-exports the public API of :mod:`~deerflow.runtime.runs` and
:mod:`~deerflow.runtime.stream_bridge` so that consumers can import
directly from ``deerflow.runtime``.
"""
from .checkpointer import checkpointer_context, get_checkpointer, make_checkpointer, reset_checkpointer
from .runs import ConflictError, DisconnectMode, RunContext, RunManager, RunRecord, RunStatus, UnsupportedStrategyError, run_agent
from .serialization import serialize, serialize_channel_values, serialize_lc_object, serialize_messages_tuple
from .store import get_store, make_store, reset_store, store_context
from .stream_bridge import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, StreamBridge, StreamEvent, make_stream_bridge
__all__ = [
# checkpointer
"checkpointer_context",
"get_checkpointer",
"make_checkpointer",
"reset_checkpointer",
# runs
"ConflictError",
"DisconnectMode",
"RunContext",
"RunManager",
"RunRecord",
"RunStatus",
"UnsupportedStrategyError",
"run_agent",
# serialization
"serialize",
"serialize_channel_values",
"serialize_lc_object",
"serialize_messages_tuple",
# store
"get_store",
"make_store",
"reset_store",
"store_context",
# stream_bridge
"END_SENTINEL",
"HEARTBEAT_SENTINEL",
"MemoryStreamBridge",
"StreamBridge",
"StreamEvent",
"make_stream_bridge",
]
"""LangGraph-compatible runtime — runs, streaming, and lifecycle management.
Re-exports the public API of :mod:`~deerflow.runtime.runs` and
:mod:`~deerflow.runtime.stream_bridge` so that consumers can import
directly from ``deerflow.runtime``.
"""
from .checkpointer import checkpointer_context, get_checkpointer, make_checkpointer, reset_checkpointer
from .runs import (
ConflictError,
DisconnectMode,
ModelConcurrencyLimitError,
RunContext,
RunManager,
RunRecord,
RunStatus,
UnsupportedStrategyError,
run_agent,
)
from .serialization import serialize, serialize_channel_values, serialize_lc_object, serialize_messages_tuple
from .store import get_store, make_store, reset_store, store_context
from .stream_bridge import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, StreamBridge, StreamEvent, make_stream_bridge
__all__ = [
# checkpointer
"checkpointer_context",
"get_checkpointer",
"make_checkpointer",
"reset_checkpointer",
# runs
"ConflictError",
"DisconnectMode",
"ModelConcurrencyLimitError",
"RunContext",
"RunManager",
"RunRecord",
"RunStatus",
"UnsupportedStrategyError",
"run_agent",
# serialization
"serialize",
"serialize_channel_values",
"serialize_lc_object",
"serialize_messages_tuple",
# store
"get_store",
"make_store",
"reset_store",
"store_context",
# stream_bridge
"END_SENTINEL",
"HEARTBEAT_SENTINEL",
"MemoryStreamBridge",
"StreamBridge",
"StreamEvent",
"make_stream_bridge",
]
@@ -1,16 +1,17 @@
"""Run lifecycle management for LangGraph Platform API compatibility."""
from .manager import ConflictError, RunManager, RunRecord, UnsupportedStrategyError
from .schemas import DisconnectMode, RunStatus
from .worker import RunContext, run_agent
__all__ = [
"ConflictError",
"DisconnectMode",
"RunContext",
"RunManager",
"RunRecord",
"RunStatus",
"UnsupportedStrategyError",
"run_agent",
]
"""Run lifecycle management for LangGraph Platform API compatibility."""
from .manager import ConflictError, ModelConcurrencyLimitError, RunManager, RunRecord, UnsupportedStrategyError
from .schemas import DisconnectMode, RunStatus
from .worker import RunContext, run_agent
__all__ = [
"ConflictError",
"DisconnectMode",
"ModelConcurrencyLimitError",
"RunContext",
"RunManager",
"RunRecord",
"RunStatus",
"UnsupportedStrategyError",
"run_agent",
]
@@ -1,310 +1,326 @@
"""In-memory run registry with optional persistent RunStore backing."""
from __future__ import annotations
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from deerflow.utils.time import now_iso as _now_iso
from .schemas import DisconnectMode, RunStatus
if TYPE_CHECKING:
from deerflow.runtime.runs.store.base import RunStore
logger = logging.getLogger(__name__)
@dataclass
class RunRecord:
"""Mutable record for a single run."""
run_id: str
thread_id: str
assistant_id: str | None
status: RunStatus
on_disconnect: DisconnectMode
multitask_strategy: str = "reject"
metadata: dict = field(default_factory=dict)
kwargs: dict = field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
task: asyncio.Task | None = field(default=None, repr=False)
abort_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
abort_action: str = "interrupt"
error: str | None = None
class RunManager:
"""In-memory run registry with optional persistent RunStore backing.
All mutations are protected by an asyncio lock. When a ``store`` is
provided, serializable metadata is also persisted to the store so
that run history survives process restarts.
"""
def __init__(self, store: RunStore | None = None) -> None:
self._runs: dict[str, RunRecord] = {}
self._lock = asyncio.Lock()
self._store = store
async def _persist_to_store(self, record: RunRecord) -> None:
"""Best-effort persist run record to backing store."""
if self._store is None:
return
try:
await self._store.put(
record.run_id,
thread_id=record.thread_id,
assistant_id=record.assistant_id,
status=record.status.value,
multitask_strategy=record.multitask_strategy,
metadata=record.metadata or {},
kwargs=record.kwargs or {},
created_at=record.created_at,
)
except Exception:
logger.warning("Failed to persist run %s to store", record.run_id, exc_info=True)
async def update_run_completion(self, run_id: str, **kwargs) -> None:
"""Persist token usage and completion data to the backing store."""
if self._store is not None:
try:
await self._store.update_run_completion(run_id, **kwargs)
except Exception:
logger.warning("Failed to persist run completion for %s", run_id, exc_info=True)
async def create(
self,
thread_id: str,
assistant_id: str | None = None,
*,
on_disconnect: DisconnectMode = DisconnectMode.cancel,
metadata: dict | None = None,
kwargs: dict | None = None,
multitask_strategy: str = "reject",
) -> RunRecord:
"""Create a new pending run and register it."""
run_id = str(uuid.uuid4())
now = _now_iso()
record = RunRecord(
run_id=run_id,
thread_id=thread_id,
assistant_id=assistant_id,
status=RunStatus.pending,
on_disconnect=on_disconnect,
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
created_at=now,
updated_at=now,
)
async with self._lock:
self._runs[run_id] = record
await self._persist_to_store(record)
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
return record
def get(self, run_id: str) -> RunRecord | None:
"""Return a run record by ID, or ``None``."""
return self._runs.get(run_id)
def _record_from_store(self, data: dict) -> RunRecord:
"""Rebuild a :class:`RunRecord` from a persisted store row.
The reconstructed record has no live ``task``/``abort_event`` wired to a
running coroutine — it represents *history*, not an in-flight run.
"""
try:
status = RunStatus(data.get("status") or RunStatus.success.value)
except ValueError:
status = RunStatus.success
return RunRecord(
run_id=data["run_id"],
thread_id=data["thread_id"],
assistant_id=data.get("assistant_id"),
status=status,
# Disconnect mode is irrelevant for a store-only record (no task to
# cancel); ``continue_`` keeps sse_consumer's finally block a no-op.
on_disconnect=DisconnectMode.continue_,
multitask_strategy=data.get("multitask_strategy", "reject"),
metadata=data.get("metadata") or {},
kwargs=data.get("kwargs") or {},
created_at=data.get("created_at") or "",
updated_at=data.get("updated_at") or "",
error=data.get("error"),
)
async def get_persisted(self, run_id: str) -> RunRecord | None:
"""Load a run from the backing store (process-restart fallback).
The in-memory registry is wiped on every Gateway restart, but the
``RunStore`` row survives. When :meth:`get` misses, callers fall back
here so a stale ``joinStream``/``GET run`` can report the run's final
status instead of 404ing. Returns ``None`` when there is no store, no
matching row, or the lookup fails (e.g. no user context).
"""
if self._store is None:
return None
try:
data = await self._store.get(run_id)
except Exception:
logger.warning("Failed to load run %s from store", run_id, exc_info=True)
return None
if not data:
return None
return self._record_from_store(data)
async def list_by_thread(self, thread_id: str) -> list[RunRecord]:
"""Return all runs for a given thread, newest first."""
async with self._lock:
# Dict insertion order matches creation order, so reversing it gives
# us deterministic newest-first results even when timestamps tie.
return [r for r in self._runs.values() if r.thread_id == thread_id]
async def set_status(self, run_id: str, status: RunStatus, *, error: str | None = None) -> None:
"""Transition a run to a new status."""
async with self._lock:
record = self._runs.get(run_id)
if record is None:
logger.warning("set_status called for unknown run %s", run_id)
return
record.status = status
record.updated_at = _now_iso()
if error is not None:
record.error = error
if self._store is not None:
try:
await self._store.update_status(run_id, status.value, error=error)
except Exception:
logger.warning("Failed to persist status update for run %s", run_id, exc_info=True)
logger.info("Run %s -> %s", run_id, status.value)
async def cancel(self, run_id: str, *, action: str = "interrupt") -> bool:
"""Request cancellation of a run.
Args:
run_id: The run ID to cancel.
action: "interrupt" keeps checkpoint, "rollback" reverts to pre-run state.
Sets the abort event with the action reason and cancels the asyncio task.
Returns ``True`` if the run was in-flight and cancellation was initiated.
"""
async with self._lock:
record = self._runs.get(run_id)
if record is None:
return False
if record.status not in (RunStatus.pending, RunStatus.running):
return False
record.abort_action = action
record.abort_event.set()
if record.task is not None and not record.task.done():
record.task.cancel()
record.status = RunStatus.interrupted
record.updated_at = _now_iso()
logger.info("Run %s cancelled (action=%s)", run_id, action)
return True
async def create_or_reject(
self,
thread_id: str,
assistant_id: str | None = None,
*,
on_disconnect: DisconnectMode = DisconnectMode.cancel,
metadata: dict | None = None,
kwargs: dict | None = None,
multitask_strategy: str = "reject",
) -> RunRecord:
"""Atomically check for inflight runs and create a new one.
For ``reject`` strategy, raises ``ConflictError`` if thread
already has a pending/running run. For ``interrupt``/``rollback``,
cancels inflight runs before creating.
This method holds the lock across both the check and the insert,
eliminating the TOCTOU race in separate ``has_inflight`` + ``create``.
"""
run_id = str(uuid.uuid4())
now = _now_iso()
_supported_strategies = ("reject", "interrupt", "rollback")
async with self._lock:
if multitask_strategy not in _supported_strategies:
raise UnsupportedStrategyError(f"Multitask strategy '{multitask_strategy}' is not yet supported. Supported strategies: {', '.join(_supported_strategies)}")
inflight = [r for r in self._runs.values() if r.thread_id == thread_id and r.status in (RunStatus.pending, RunStatus.running)]
if multitask_strategy == "reject" and inflight:
raise ConflictError(f"Thread {thread_id} already has an active run")
if multitask_strategy in ("interrupt", "rollback") and inflight:
for r in inflight:
r.abort_action = multitask_strategy
r.abort_event.set()
if r.task is not None and not r.task.done():
r.task.cancel()
r.status = RunStatus.interrupted
r.updated_at = now
logger.info(
"Cancelled %d inflight run(s) on thread %s (strategy=%s)",
len(inflight),
thread_id,
multitask_strategy,
)
record = RunRecord(
run_id=run_id,
thread_id=thread_id,
assistant_id=assistant_id,
status=RunStatus.pending,
on_disconnect=on_disconnect,
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
created_at=now,
updated_at=now,
)
self._runs[run_id] = record
await self._persist_to_store(record)
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
return record
async def has_inflight(self, thread_id: str) -> bool:
"""Return ``True`` if *thread_id* has a pending or running run."""
async with self._lock:
return any(r.thread_id == thread_id and r.status in (RunStatus.pending, RunStatus.running) for r in self._runs.values())
async def list_active(self) -> list[RunRecord]:
"""Return every in-flight run (status pending/running), oldest first.
Powers the admin concurrency monitor: each record represents one live
call into the model runtime (interactive chat, agent chat, roundtable
seat, …), so ``len(list_active())`` is the current concurrent-call count
and each record can be cancelled to relieve model pressure. Completed
records linger in ``_runs`` until :meth:`cleanup`, so we filter by status
rather than returning the whole registry.
"""
async with self._lock:
# Dict insertion order == creation order, so this is oldest-first.
return [r for r in self._runs.values() if r.status in (RunStatus.pending, RunStatus.running)]
async def cleanup(self, run_id: str, *, delay: float = 300) -> None:
"""Remove a run record after an optional delay."""
if delay > 0:
await asyncio.sleep(delay)
async with self._lock:
self._runs.pop(run_id, None)
logger.debug("Run record %s cleaned up", run_id)
class ConflictError(Exception):
"""Raised when multitask_strategy=reject and thread has inflight runs."""
class UnsupportedStrategyError(Exception):
"""Raised when a multitask_strategy value is not yet implemented."""
"""In-memory run registry with optional persistent RunStore backing."""
from __future__ import annotations
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from deerflow.utils.time import now_iso as _now_iso
from .schemas import DisconnectMode, RunStatus
if TYPE_CHECKING:
from deerflow.runtime.runs.store.base import RunStore
logger = logging.getLogger(__name__)
@dataclass
class RunRecord:
"""Mutable record for a single run."""
run_id: str
thread_id: str
assistant_id: str | None
status: RunStatus
on_disconnect: DisconnectMode
multitask_strategy: str = "reject"
metadata: dict = field(default_factory=dict)
kwargs: dict = field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
task: asyncio.Task | None = field(default=None, repr=False)
abort_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
abort_action: str = "interrupt"
error: str | None = None
class RunManager:
"""In-memory run registry with optional persistent RunStore backing.
All mutations are protected by an asyncio lock. When a ``store`` is
provided, serializable metadata is also persisted to the store so
that run history survives process restarts.
"""
def __init__(self, store: RunStore | None = None) -> None:
self._runs: dict[str, RunRecord] = {}
self._lock = asyncio.Lock()
self._store = store
async def _persist_to_store(self, record: RunRecord) -> None:
"""Best-effort persist run record to backing store."""
if self._store is None:
return
try:
await self._store.put(
record.run_id,
thread_id=record.thread_id,
assistant_id=record.assistant_id,
status=record.status.value,
multitask_strategy=record.multitask_strategy,
metadata=record.metadata or {},
kwargs=record.kwargs or {},
created_at=record.created_at,
)
except Exception:
logger.warning("Failed to persist run %s to store", record.run_id, exc_info=True)
async def update_run_completion(self, run_id: str, **kwargs) -> None:
"""Persist token usage and completion data to the backing store."""
if self._store is not None:
try:
await self._store.update_run_completion(run_id, **kwargs)
except Exception:
logger.warning("Failed to persist run completion for %s", run_id, exc_info=True)
async def create(
self,
thread_id: str,
assistant_id: str | None = None,
*,
on_disconnect: DisconnectMode = DisconnectMode.cancel,
metadata: dict | None = None,
kwargs: dict | None = None,
multitask_strategy: str = "reject",
) -> RunRecord:
"""Create a new pending run and register it."""
run_id = str(uuid.uuid4())
now = _now_iso()
record = RunRecord(
run_id=run_id,
thread_id=thread_id,
assistant_id=assistant_id,
status=RunStatus.pending,
on_disconnect=on_disconnect,
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
created_at=now,
updated_at=now,
)
async with self._lock:
self._runs[run_id] = record
await self._persist_to_store(record)
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
return record
def get(self, run_id: str) -> RunRecord | None:
"""Return a run record by ID, or ``None``."""
return self._runs.get(run_id)
def _record_from_store(self, data: dict) -> RunRecord:
"""Rebuild a :class:`RunRecord` from a persisted store row.
The reconstructed record has no live ``task``/``abort_event`` wired to a
running coroutine — it represents *history*, not an in-flight run.
"""
try:
status = RunStatus(data.get("status") or RunStatus.success.value)
except ValueError:
status = RunStatus.success
return RunRecord(
run_id=data["run_id"],
thread_id=data["thread_id"],
assistant_id=data.get("assistant_id"),
status=status,
# Disconnect mode is irrelevant for a store-only record (no task to
# cancel); ``continue_`` keeps sse_consumer's finally block a no-op.
on_disconnect=DisconnectMode.continue_,
multitask_strategy=data.get("multitask_strategy", "reject"),
metadata=data.get("metadata") or {},
kwargs=data.get("kwargs") or {},
created_at=data.get("created_at") or "",
updated_at=data.get("updated_at") or "",
error=data.get("error"),
)
async def get_persisted(self, run_id: str) -> RunRecord | None:
"""Load a run from the backing store (process-restart fallback).
The in-memory registry is wiped on every Gateway restart, but the
``RunStore`` row survives. When :meth:`get` misses, callers fall back
here so a stale ``joinStream``/``GET run`` can report the run's final
status instead of 404ing. Returns ``None`` when there is no store, no
matching row, or the lookup fails (e.g. no user context).
"""
if self._store is None:
return None
try:
data = await self._store.get(run_id)
except Exception:
logger.warning("Failed to load run %s from store", run_id, exc_info=True)
return None
if not data:
return None
return self._record_from_store(data)
async def list_by_thread(self, thread_id: str) -> list[RunRecord]:
"""Return all runs for a given thread, newest first."""
async with self._lock:
# Dict insertion order matches creation order, so reversing it gives
# us deterministic newest-first results even when timestamps tie.
return [r for r in self._runs.values() if r.thread_id == thread_id]
async def set_status(self, run_id: str, status: RunStatus, *, error: str | None = None) -> None:
"""Transition a run to a new status."""
async with self._lock:
record = self._runs.get(run_id)
if record is None:
logger.warning("set_status called for unknown run %s", run_id)
return
record.status = status
record.updated_at = _now_iso()
if error is not None:
record.error = error
if self._store is not None:
try:
await self._store.update_status(run_id, status.value, error=error)
except Exception:
logger.warning("Failed to persist status update for run %s", run_id, exc_info=True)
logger.info("Run %s -> %s", run_id, status.value)
async def cancel(self, run_id: str, *, action: str = "interrupt") -> bool:
"""Request cancellation of a run.
Args:
run_id: The run ID to cancel.
action: "interrupt" keeps checkpoint, "rollback" reverts to pre-run state.
Sets the abort event with the action reason and cancels the asyncio task.
Returns ``True`` if the run was in-flight and cancellation was initiated.
"""
async with self._lock:
record = self._runs.get(run_id)
if record is None:
return False
if record.status not in (RunStatus.pending, RunStatus.running):
return False
record.abort_action = action
record.abort_event.set()
if record.task is not None and not record.task.done():
record.task.cancel()
record.status = RunStatus.interrupted
record.updated_at = _now_iso()
logger.info("Run %s cancelled (action=%s)", run_id, action)
return True
async def create_or_reject(
self,
thread_id: str,
assistant_id: str | None = None,
*,
on_disconnect: DisconnectMode = DisconnectMode.cancel,
metadata: dict | None = None,
kwargs: dict | None = None,
multitask_strategy: str = "reject",
model_concurrency_limit: tuple[str, int] | None = None,
) -> RunRecord:
"""Atomically check for inflight runs and create a new one.
For ``reject`` strategy, raises ``ConflictError`` if thread
already has a pending/running run. For ``interrupt``/``rollback``,
cancels inflight runs before creating.
This method holds the lock across both the check and the insert,
eliminating the TOCTOU race in separate ``has_inflight`` + ``create``.
"""
run_id = str(uuid.uuid4())
now = _now_iso()
_supported_strategies = ("reject", "interrupt", "rollback")
async with self._lock:
if multitask_strategy not in _supported_strategies:
raise UnsupportedStrategyError(f"Multitask strategy '{multitask_strategy}' is not yet supported. Supported strategies: {', '.join(_supported_strategies)}")
inflight = [r for r in self._runs.values() if r.thread_id == thread_id and r.status in (RunStatus.pending, RunStatus.running)]
if multitask_strategy == "reject" and inflight:
raise ConflictError(f"Thread {thread_id} already has an active run")
if multitask_strategy in ("interrupt", "rollback") and inflight:
for r in inflight:
r.abort_action = multitask_strategy
r.abort_event.set()
if r.task is not None and not r.task.done():
r.task.cancel()
r.status = RunStatus.interrupted
r.updated_at = now
logger.info(
"Cancelled %d inflight run(s) on thread %s (strategy=%s)",
len(inflight),
thread_id,
multitask_strategy,
)
if model_concurrency_limit is not None:
model_name, limit = model_concurrency_limit
active_for_model = sum(1 for r in self._runs.values() if r.status in (RunStatus.pending, RunStatus.running) and str((r.metadata or {}).get("model_name") or "").strip() == model_name)
if active_for_model >= max(1, int(limit)):
raise ModelConcurrencyLimitError(model_name, limit)
record = RunRecord(
run_id=run_id,
thread_id=thread_id,
assistant_id=assistant_id,
status=RunStatus.pending,
on_disconnect=on_disconnect,
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
created_at=now,
updated_at=now,
)
self._runs[run_id] = record
await self._persist_to_store(record)
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
return record
async def has_inflight(self, thread_id: str) -> bool:
"""Return ``True`` if *thread_id* has a pending or running run."""
async with self._lock:
return any(r.thread_id == thread_id and r.status in (RunStatus.pending, RunStatus.running) for r in self._runs.values())
async def list_active(self) -> list[RunRecord]:
"""Return every in-flight run (status pending/running), oldest first.
Powers the admin concurrency monitor: each record represents one live
call into the model runtime (interactive chat, agent chat, roundtable
seat, …), so ``len(list_active())`` is the current concurrent-call count
and each record can be cancelled to relieve model pressure. Completed
records linger in ``_runs`` until :meth:`cleanup`, so we filter by status
rather than returning the whole registry.
"""
async with self._lock:
# Dict insertion order == creation order, so this is oldest-first.
return [r for r in self._runs.values() if r.status in (RunStatus.pending, RunStatus.running)]
async def cleanup(self, run_id: str, *, delay: float = 300) -> None:
"""Remove a run record after an optional delay."""
if delay > 0:
await asyncio.sleep(delay)
async with self._lock:
self._runs.pop(run_id, None)
logger.debug("Run record %s cleaned up", run_id)
class ConflictError(Exception):
"""Raised when multitask_strategy=reject and thread has inflight runs."""
class ModelConcurrencyLimitError(Exception):
"""Raised when a model-specific in-flight run limit has been reached."""
def __init__(self, model_name: str, limit: int) -> None:
self.model_name = model_name
self.limit = limit
super().__init__(f"Model {model_name} has reached its concurrency limit")
class UnsupportedStrategyError(Exception):
"""Raised when a multitask_strategy value is not yet implemented."""
@@ -15,10 +15,10 @@ internal checkpoint callbacks that are not exposed in the Python public API.
from __future__ import annotations
import asyncio
import copy
import inspect
import logging
import asyncio
import copy
import inspect
import logging
from dataclasses import dataclass, field
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Literal, cast
@@ -28,17 +28,31 @@ from langgraph.checkpoint.base import empty_checkpoint
if TYPE_CHECKING:
from langchain_core.messages import HumanMessage
from deerflow.config.app_config import AppConfig
from deerflow.runtime.serialization import serialize
from deerflow.runtime.stream_bridge import StreamBridge
from deerflow.agents.middlewares.title_middleware import (
finalize_thread_title,
log_title_debug,
)
from deerflow.config.app_config import AppConfig
from deerflow.runtime.serialization import serialize
from deerflow.runtime.stream_bridge import StreamBridge
from .manager import RunManager, RunRecord
from .schemas import RunStatus
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Valid stream_mode values for LangGraph's graph.astream()
_VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"}
_VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"}
def _log_background_title_finalize_failure(thread_id: str):
def _done(task: asyncio.Task[bool]) -> None:
try:
task.result()
except Exception:
logger.debug("Failed to finalize background title for thread %s", thread_id, exc_info=True)
return _done
def _build_runtime_context(
@@ -400,18 +414,47 @@ async def run_agent(
except Exception:
logger.warning("Failed to persist run completion for %s (non-fatal)", run_id, exc_info=True)
# Sync title from checkpoint to threads_meta.display_name
if checkpointer is not None and thread_store is not None:
try:
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
if ckpt_tuple is not None:
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
title = ckpt.get("channel_values", {}).get("title")
if title:
await thread_store.update_display_name(thread_id, title)
except Exception:
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
# Sync title from checkpoint to threads_meta.display_name
if checkpointer is not None and thread_store is not None:
try:
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
if ckpt_tuple is not None:
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
channel_values = ckpt.get("channel_values", {}) or {}
title = channel_values.get("title")
if title:
await thread_store.update_display_name(thread_id, title)
if (
record.status == RunStatus.success
and channel_values.get("title_provisional") is True
):
log_title_debug(
"Run %s: scheduling background title generation for thread %s",
run_id,
thread_id,
)
title_task = asyncio.create_task(
finalize_thread_title(
checkpointer=checkpointer,
thread_store=thread_store,
thread_id=thread_id,
source_state=copy.deepcopy(channel_values),
app_config=ctx.app_config,
),
name=f"title-finalize:{thread_id}",
)
title_task.add_done_callback(_log_background_title_finalize_failure(thread_id))
elif record.status == RunStatus.success:
log_title_debug(
"Run %s: background title generation not scheduled for thread %s because title_provisional=%r title=%r",
run_id,
thread_id,
channel_values.get("title_provisional"),
channel_values.get("title"),
)
except Exception:
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
# Update threads_meta status based on run outcome
if thread_store is not None:
@@ -22,10 +22,10 @@ from langgraph.checkpoint.base import empty_checkpoint
from deerflow.config.agents_config import AGENT_ID_PATTERN, load_agent_config, load_agent_soul
from deerflow.config.paths import get_paths
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.scheduler import html_page as html_page_utils
from deerflow.runtime.scheduler import template_fill
from deerflow.runtime.scheduler.element import ElementDeliveryClient
from deerflow.runtime.serialization import serialize_channel_values
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.time import now_iso
@@ -267,6 +267,69 @@ TEMPLATES: tuple[TemplateSpec, ...] = (
file="no18/no18.html",
convertible=False,
),
TemplateSpec(
id="no19",
name="no.19",
description="",
file="no19/no.19.html",
convertible=False,
),
TemplateSpec(
id="no20",
name="no.20",
description="",
file="no20/no.20.html",
convertible=False,
),
TemplateSpec(
id="no21",
name="no.21",
description="",
file="no21/no.21.html",
convertible=False,
),
TemplateSpec(
id="no22",
name="no.22",
description="",
file="no22/no.22.html",
convertible=False,
),
TemplateSpec(
id="no23",
name="no.23",
description="",
file="no23/no.23.html",
convertible=False,
),
TemplateSpec(
id="no24",
name="no.24",
description="",
file="no24/no.24.html",
convertible=False,
),
TemplateSpec(
id="no25",
name="no.25",
description="",
file="no25/no.25.html",
convertible=False,
),
TemplateSpec(
id="no26",
name="no.26",
description="",
file="no26/no.26.html",
convertible=False,
),
TemplateSpec(
id="no27",
name="no.27",
description="",
file="no27/no.27.html",
convertible=False,
),
)
_TEMPLATES_BY_ID: dict[str, TemplateSpec] = {t.id: t for t in TEMPLATES}
@@ -0,0 +1,381 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>日本涉台介入风险叙事专题研究系统</title>
<style>
:root {
--bg: #8f1f25;
--bg2: #b8323a;
--panel: #fff7f3;
--card: #ffffff;
--ink: #241616;
--muted: #76605d;
--line: rgba(120, 29, 35, .18);
--red: #b7202e;
--red-dark: #7b121b;
--gold: #f3c969;
--soft: #fff2eb;
--green: #18724d;
--orange: #b35b13;
--blue: #275b91;
--shadow: 0 18px 50px rgba(58, 9, 15, .18);
--radius: 18px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
background: radial-gradient(circle at 20% 0%, #d94c4c 0, transparent 24%), linear-gradient(140deg, var(--bg), #641018 70%);
color: var(--ink);
min-height: 100vh;
}
.app { max-width: 1480px; margin: 0 auto; padding: 28px; }
.hero {
color: #fff;
padding: 24px 26px 22px;
border: 1px solid rgba(255,255,255,.18);
background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.06));
backdrop-filter: blur(10px);
border-radius: 26px;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.hero::after {
content: "";
position: absolute;
width: 420px; height: 420px;
border: 1px solid rgba(255,255,255,.12);
border-radius: 50%;
right: -160px; top: -220px;
}
.eyebrow { font-size: 13px; letter-spacing: .18em; opacity: .86; margin-bottom: 9px; }
h1 { margin: 0; font-size: clamp(28px, 4vw, 48px); line-height: 1.15; }
.subtitle { max-width: 920px; margin-top: 12px; color: rgba(255,255,255,.88); line-height: 1.75; }
.hero-meta { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 18px; }
.pill { display:inline-flex; align-items:center; gap:6px; padding: 7px 11px; border-radius: 999px; background: rgba(255,255,255,.16); color: #fff; border: 1px solid rgba(255,255,255,.18); font-size: 13px; }
.toolbar {
margin: 20px 0 14px;
background: var(--panel);
border-radius: 22px;
padding: 14px;
display: grid;
grid-template-columns: 1.6fr .85fr .75fr .75fr;
gap: 12px;
box-shadow: var(--shadow);
border: 1px solid rgba(255,255,255,.42);
}
input, select {
width: 100%;
border: 1px solid var(--line);
background: #fff;
border-radius: 14px;
padding: 12px 13px;
font-size: 14px;
color: var(--ink);
outline: none;
}
input:focus, select:focus { border-color: rgba(183,32,46,.56); box-shadow: 0 0 0 3px rgba(183,32,46,.1); }
.tabs {
display: flex; flex-wrap: wrap; gap: 9px;
margin-bottom: 18px;
}
.tab-btn {
border: 0;
background: rgba(255,255,255,.18);
color: #fff;
border: 1px solid rgba(255,255,255,.2);
padding: 10px 13px;
border-radius: 999px;
cursor: pointer;
font-weight: 700;
transition: .18s ease;
}
.tab-btn.active { background: #fff; color: var(--red-dark); box-shadow: 0 10px 26px rgba(0,0,0,.13); }
.tab-btn:hover { transform: translateY(-1px); }
.panel {
background: var(--panel);
border-radius: 26px;
padding: 20px;
box-shadow: var(--shadow);
border: 1px solid rgba(255,255,255,.44);
min-height: 520px;
}
.tab-head { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; margin-bottom:16px; }
.tab-title { margin: 0; font-size: 25px; color: var(--red-dark); }
.tab-desc { margin: 6px 0 0; color: var(--muted); line-height: 1.6; }
.count-badge { flex: 0 0 auto; padding: 8px 11px; border-radius: 999px; background:#fff; color: var(--red-dark); border:1px solid var(--line); font-weight:800; }
.summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.summary-card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
min-height: 144px;
position: relative;
overflow: hidden;
}
.summary-card::after { content:""; position:absolute; right:-38px; top:-42px; width: 110px; height:110px; border-radius:50%; background: rgba(183,32,46,.08); }
.summary-card h3 { margin:0 0 8px; color:var(--red-dark); font-size:15px; }
.summary-card .value { font-size: 25px; font-weight: 900; margin-bottom: 9px; color: #2b1114; }
.summary-card p { margin:0; color:var(--muted); line-height: 1.62; font-size: 14px; }
.overview-grid { display:grid; grid-template-columns: 1.1fr .9fr; gap: 14px; margin-top: 14px; }
.box { background:#fff; border:1px solid var(--line); border-radius:18px; padding:16px; }
.box h3 { margin:0 0 12px; color: var(--red-dark); }
.bar-row { display:grid; grid-template-columns: 105px 1fr 36px; align-items:center; gap:8px; margin: 9px 0; font-size:13px; color: var(--muted); }
.bar { height: 10px; border-radius: 999px; background: #f4ded8; overflow: hidden; }
.bar > span { display:block; height:100%; border-radius:999px; background: linear-gradient(90deg, var(--red), var(--gold)); }
.risk-list { display:grid; gap:10px; }
.risk-mini { border:1px solid var(--line); border-radius:14px; padding:11px; background:#fffdfb; cursor:pointer; }
.risk-mini strong { color: var(--red-dark); }
.cards { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.card {
background: #fff;
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
display:flex; flex-direction:column; gap: 10px;
min-height: 248px;
transition: .18s ease;
}
.card:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(94, 20, 24, .12); }
.card-top { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
.card-id { font-size:12px; color:#fff; background: var(--red-dark); border-radius:999px; padding:4px 8px; font-weight: 800; }
.risk { font-size:12px; border-radius:999px; padding:5px 8px; font-weight: 800; white-space: nowrap; }
.risk.xh { background: #ffe1de; color:#9b111c; }
.risk.h { background: #fff0d5; color:#9a4e00; }
.risk.m { background: #e9f2ff; color:#24527d; }
.card h3 { margin:0; color:var(--red-dark); font-size:17px; line-height:1.35; }
.meta { display:flex; gap:6px; flex-wrap:wrap; }
.tag { font-size:12px; color:var(--red-dark); background: var(--soft); border:1px solid rgba(183,32,46,.15); border-radius:999px; padding:4px 8px; }
.card p { margin:0; color:var(--muted); line-height: 1.62; font-size: 14px; }
.card-footer { margin-top:auto; display:flex; justify-content:space-between; align-items:center; gap:12px; }
.evidence { font-size:12px; color:#5e4d49; }
.btn {
background: var(--red);
color:#fff;
border:0;
border-radius:12px;
padding:9px 12px;
cursor:pointer;
font-weight:800;
box-shadow: 0 8px 18px rgba(183,32,46,.22);
}
.btn.secondary { background:#fff; color:var(--red-dark); border:1px solid var(--line); box-shadow:none; }
.empty { padding: 38px; text-align:center; color:var(--muted); background:#fff; border:1px dashed var(--line); border-radius:18px; }
.keyword-wrap { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:14px; margin-top:16px; }
.keyword-group { background:#fff; border:1px solid var(--line); border-radius:18px; padding:15px; }
.keyword-group h3 { margin:0 0 10px; color: var(--red-dark); }
.kw { display:inline-block; margin:5px; padding:7px 10px; background:var(--soft); border:1px solid rgba(183,32,46,.18); color:var(--red-dark); border-radius:999px; font-size:13px; }
.modal-backdrop { display:none; position:fixed; inset:0; background:rgba(45, 7, 10, .62); z-index:1000; padding: 28px; overflow:auto; }
.modal-backdrop.open { display:block; }
.modal { max-width: 980px; margin: 0 auto; background:#fffaf7; border-radius: 24px; border:1px solid rgba(255,255,255,.45); box-shadow: 0 30px 90px rgba(0,0,0,.34); overflow:hidden; }
.modal-head { background: linear-gradient(135deg, var(--red-dark), var(--red)); color:#fff; padding: 20px 22px; display:flex; justify-content:space-between; gap:16px; align-items:flex-start; }
.modal-head h2 { margin:0; font-size:24px; }
.close { background:rgba(255,255,255,.16); border:1px solid rgba(255,255,255,.25); color:#fff; border-radius:12px; font-size:22px; cursor:pointer; width:42px; height:38px; }
.modal-body { padding: 20px 22px 24px; }
.detail-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap:10px; margin-bottom:16px; }
.detail-cell { background:#fff; border:1px solid var(--line); border-radius:14px; padding:10px; }
.detail-cell span { display:block; font-size:12px; color:var(--muted); margin-bottom:4px; }
.detail-cell strong { color:var(--red-dark); }
.section { background:#fff; border:1px solid var(--line); border-radius:16px; padding:15px; margin:12px 0; }
.section h3 { margin:0 0 8px; color:var(--red-dark); }
.section p { margin:0; line-height:1.8; color:#3b2929; }
.footer-note { color:rgba(255,255,255,.78); text-align:center; padding: 16px 0 4px; font-size:13px; }
@media (max-width: 1100px) {
.toolbar { grid-template-columns: 1fr 1fr; }
.summary-grid, .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.overview-grid { grid-template-columns: 1fr; }
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.app { padding: 14px; }
.toolbar { grid-template-columns: 1fr; }
.summary-grid, .cards, .keyword-wrap { grid-template-columns: 1fr; }
.tab-head { flex-direction:column; }
.detail-grid { grid-template-columns: 1fr; }
.modal-backdrop { padding: 12px; }
}
</style>
</head>
<body>
<div class="app">
<header class="hero">
<div class="eyebrow">NARRATIVE RISK MONITORING DATABASE</div>
<h1>日本涉台介入风险叙事专题研究系统</h1>
<div class="subtitle">围绕“介入战争日本祸及自身”叙事的事件、人物、组织、制度、地方、媒体、经济与民意数据库</div>
<div class="hero-meta">
<span class="pill">版本:1.0</span>
<span class="pill">更新:2026-06-30</span>
<span class="pill">离线可用 · 内嵌数据</span>
<span class="pill">Tab切换 · 搜索筛选 · 弹框详情</span>
</div>
</header>
<section class="toolbar">
<input id="q" placeholder="搜索关键词:高市、先岛、存立危机、冲绳、旅游、认知战……" />
<select id="narrativeFilter"><option value="">全部叙事方向</option></select>
<select id="riskFilter"><option value="">全部风险等级</option><option value="极高">极高</option><option value="高">高</option><option value="中">中</option><option value="中低">中低</option><option value="低">低</option></select>
<select id="evidenceFilter"><option value="">全部证据状态</option><option value="已核实">已核实</option><option value="持续更新">持续更新</option><option value="待采集">待采集</option></select>
</section>
<nav class="tabs" id="tabs"></nav>
<main class="panel" id="content"></main>
<div class="footer-note">本页面为研究型结构化展示,不替代原始文件核验;高风险条目应回溯国会记录、政府文件、机构公告和媒体原文。</div>
</div>
<div class="modal-backdrop" id="modalBackdrop" onclick="backdropClose(event)">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-head">
<div>
<div id="modalMeta" class="eyebrow"></div>
<h2 id="modalTitle"></h2>
</div>
<button class="close" onclick="closeModal()">×</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<script>
const DATA = {"project": {"title": "日本涉台介入风险叙事专题研究系统", "subtitle": "围绕“介入战争日本祸及自身”叙事的事件、人物、组织、制度、地方、媒体、经济与民意数据库", "version": "1.0", "language": "zh-CN", "last_updated": "2026-06-30", "description": "本数据用于展示日本方向涉台介入风险叙事中的事件、人物、组织、制度、地方、媒体、经济、民意与竞争叙事。"}, "tabs": [{"id": "overview", "name": "总览仪表盘", "dataset": null, "description": "展示专题总风险、叙事分布、热点地区、重点人物和最新事件。"}, {"id": "timeline", "name": "事件时间线", "dataset": "events_timeline", "description": "展示2014年以来日本涉台安全政策、政要表态、地方避难和中日冲突节点。"}, {"id": "actors_orgs", "name": "人物组织", "dataset": ["actors_japan", "organizations"], "description": "展示日本政府、政党、地方政治、专家、媒体和社会组织。"}, {"id": "security_laws", "name": "法律制度", "dataset": "security_laws", "description": "展示存立危机、重要影响、国民保护、日美安保等制度路径。"}, {"id": "local_frontline", "name": "地方前线", "dataset": "local_frontline", "description": "展示冲绳、先岛、石垣、宫古、与那国、九州、山口等地方风险。"}, {"id": "antiwar_networks", "name": "反战护宪网络", "dataset": "antiwar_networks", "description": "展示九条之会、日弁连、冲绳和平运动、地方反导弹组织和工会网络。"}, {"id": "conservative_backlash", "name": "保守反弹", "dataset": "conservative_backlash", "description": "展示威慑、防卫、中国认知战、台湾伙伴等竞争叙事。"}, {"id": "media_platform", "name": "媒体平台", "dataset": "media_platform_samples", "description": "展示全国媒体、地方媒体、YouTube、X、Yahoo、note等传播样本。"}, {"id": "economic_spillover", "name": "经济外溢", "dataset": "economic_spillover", "description": "展示旅游、航空、半导体、供应链、出口管制等风险。"}, {"id": "public_opinion", "name": "民意受众", "dataset": "public_opinion_youth", "description": "展示台湾有事、防卫费、修宪、青年、都市无党派和冲绳地方态度。"}, {"id": "japan_taiwan", "name": "日台关系", "dataset": "japan_taiwan_relations", "description": "展示日台实务窗口、亲台议员网络、地方交流、半导体合作和智库推演。"}, {"id": "corpus_keywords", "name": "语料词库", "dataset": "source_corpus_keywords", "description": "展示原始语料来源、日文关键词、平台监测词和证据状态。"}], "dashboard": {"summary_cards": [{"title": "核心叙事", "value": "介入战争日本祸及自身", "description": "围绕日本涉台介入可能引发战争卷入、地方牺牲、经济反制和社会撕裂的风险叙事。"}, {"title": "数据条目", "value": "164", "description": "覆盖事件、人物、组织、制度、地方、媒体、经济、民意和日台关系。"}, {"title": "高风险地区", "value": "冲绳、先岛、九州", "description": "冲绳本岛、石垣、宫古、与那国、九州和山口是地方风险最集中的区域。"}, {"title": "核心法律入口", "value": "存立危机事态", "description": "台湾有事是否触发日本集体自卫权,是舆论和国会争议核心。"}, {"title": "主要竞争叙事", "value": "威慑才能防止战争", "description": "保守派和安保界常用威慑、防卫、中国威胁等逻辑反击反介入叙事。"}, {"title": "总体风险", "value": "86 / 100", "description": "综合政治触发、地方影响、经济外溢、媒体扩散和反弹强度评估。"}], "narrative_distribution": [{"label": "地方牺牲", "count": 38}, {"label": "护宪反战", "count": 22}, {"label": "威慑防战", "count": 20}, {"label": "经济代价", "count": 18}, {"label": "中性解释", "count": 17}, {"label": "台湾伙伴", "count": 15}, {"label": "战争卷入", "count": 12}, {"label": "专业治理", "count": 12}, {"label": "中国胁迫", "count": 6}, {"label": "中国威胁", "count": 4}], "hotspots": [{"location": "日本", "count": 42}, {"location": "东京", "count": 15}, {"location": "冲绳", "count": 12}, {"location": "日本全国", "count": 9}, {"location": "宫古岛", "count": 5}, {"location": "华盛顿", "count": 4}, {"location": "与那国", "count": 4}, {"location": "石垣", "count": 3}, {"location": "九州/山口", "count": 3}, {"location": "熊本", "count": 3}, {"location": "先岛/九州/山口", "count": 2}, {"location": "中日之间", "count": 2}], "top_risks": [{"id": "E016", "title_cn": "高市早苗称台湾有事可能构成存立危机事态", "category": "首相表态", "summary": "高市早苗在国会答辩中将台湾有事与存立危机事态直接相连。", "detail": "该事件将保守派长期话语推入首相级官Line truncated
const state = { activeTab: 'overview' };
const tabsEl = document.getElementById('tabs');
const contentEl = document.getElementById('content');
const qEl = document.getElementById('q');
const narrativeEl = document.getElementById('narrativeFilter');
const riskEl = document.getElementById('riskFilter');
const evidenceEl = document.getElementById('evidenceFilter');
function init() {
Object.values(DATA.narrative_labels).forEach(label => {
const opt = document.createElement('option'); opt.value = label; opt.textContent = label; narrativeEl.appendChild(opt);
});
renderTabs();
render();
[qEl, narrativeEl, riskEl, evidenceEl].forEach(el => el.addEventListener('input', render));
}
function renderTabs() {
tabsEl.innerHTML = '';
DATA.tabs.forEach(t => {
const btn = document.createElement('button');
btn.className = 'tab-btn' + (state.activeTab === t.id ? ' active' : '');
btn.textContent = t.name;
btn.onclick = () => { state.activeTab = t.id; qEl.value=''; renderTabs(); render(); };
tabsEl.appendChild(btn);
});
}
function getActiveTab() { return DATA.tabs.find(t => t.id === state.activeTab); }
function getItems(tab) {
if (!tab || !tab.dataset) return [];
if (Array.isArray(tab.dataset)) return tab.dataset.flatMap(k => DATA.datasets[k] || []);
return DATA.datasets[tab.dataset] || [];
}
function textOf(x) {
return [x.id, x.title_cn, x.category, x.summary, x.detail, x.location, x.narrative_label, ...(x.tags||[])].join(' ').toLowerCase();
}
function filtered(items) {
const q = qEl.value.trim().toLowerCase();
const n = narrativeEl.value;
const r = riskEl.value;
const e = evidenceEl.value;
return items.filter(x => (!q || textOf(x).includes(q)) && (!n || x.narrative_label === n) && (!r || x.risk_level === r) && (!e || x.evidence_status === e));
}
function riskClass(level) { return level === '极高' ? 'xh' : level === '高' ? 'h' : 'm'; }
function render() {
const tab = getActiveTab();
if (state.activeTab === 'overview') return renderOverview();
const items = filtered(getItems(tab));
const keywordBlock = state.activeTab === 'corpus_keywords' ? renderKeywords() : '';
contentEl.innerHTML = `
<div class="tab-head">
<div><h2 class="tab-title">${tab.name}</h2><p class="tab-desc">${tab.description}</p></div>
<div class="count-badge">${items.length} 条</div>
</div>
${items.length ? `<div class="cards">${items.map(card).join('')}</div>` : '<div class="empty">没有符合当前筛选条件的数据。</div>'}
${keywordBlock}
`;
}
function renderOverview() {
const d = DATA.dashboard;
const maxNarr = Math.max(...d.narrative_distribution.map(x => x.count));
const maxHot = Math.max(...d.hotspots.map(x => x.count));
contentEl.innerHTML = `
<div class="tab-head">
<div><h2 class="tab-title">总览仪表盘</h2><p class="tab-desc">展示专题总风险、叙事分布、热点地区、重点人物和最新事件。可通过上方搜索框对全库进行粗略检索。</p></div>
<div class="count-badge">${Object.values(DATA.datasets).filter(Array.isArray).flat().length - DATA.datasets.keywords.length} 条</div>
</div>
<div class="summary-grid">${d.summary_cards.map(c => `<div class="summary-card"><h3>${c.title}</h3><div class="value">${c.value}</div><p>${c.description}</p></div>`).join('')}</div>
<div class="overview-grid">
<div class="box"><h3>叙事分布</h3>${d.narrative_distribution.map(x => `<div class="bar-row"><span>${x.label}</span><div class="bar"><span style="width:${Math.max(8, x.count/maxNarr*100)}%"></span></div><b>${x.count}</b></div>`).join('')}</div>
<div class="box"><h3>热点地区</h3>${d.hotspots.slice(0,10).map(x => `<div class="bar-row"><span>${x.location}</span><div class="bar"><span style="width:${Math.max(8, x.count/maxHot*100)}%"></span></div><b>${x.count}</b></div>`).join('')}</div>
</div>
<div class="overview-grid">
<div class="box"><h3>高风险条目</h3><div class="risk-list">${d.top_risks.slice(0,6).map(x => `<div class="risk-mini" onclick="openModalById('${x.id}')"><strong>${x.title_cn}</strong><br><span>${x.category} · 风险 ${x.risk_score} · ${x.narrative_label}</span></div>`).join('')}</div></div>
<div class="box"><h3>主要风险驱动</h3><div class="meta">${d.risk_drivers.map(x => `<span class="tag">${x}</span>`).join('')}</div><div class="section"><h3>页面使用建议</h3><p>先从“事件时间线”定位触发节点,再到“法律制度”查看制度路径;涉及地方影响时进入“地方前线”和“反战护宪网络”;涉及传播扩散时进入“媒体平台”;涉及成本量化时进入“经济外溢”和“民意受众”。</p></div></div>
</div>
`;
}
function card(x) {
return `<article class="card">
<div class="card-top"><span class="card-id">${x.id}</span><span class="risk ${riskClass(x.risk_level)}">${x.risk_level} · ${x.risk_score}</span></div>
<h3>${x.title_cn}</h3>
<div class="meta"><span class="tag">${x.category}</span><span class="tag">${x.narrative_label}</span><span class="tag">${x.location || '—'}</span></div>
<p>${x.summary}</p>
<div class="meta">${(x.tags || []).slice(0,4).map(t => `<span class="tag">${t}</span>`).join('')}</div>
<div class="card-footer"><span class="evidence">证据:${x.evidence_status} · 可信度${x.confidence_level}</span><button class="btn" onclick="openModalById('${x.id}')">查看详情</button></div>
</article>`;
}
function renderKeywords() {
return `<div class="keyword-wrap">${DATA.datasets.keywords.map(g => `<div class="keyword-group"><h3>${g.group}</h3>${g.terms.map(t => `<span class="kw">${t}</span>`).join('')}</div>`).join('')}</div>`;
}
function allItems() { return Object.entries(DATA.datasets).filter(([k]) => k !== 'keywords').flatMap(([k,v]) => Array.isArray(v) ? v : []); }
function openModalById(id) { const x = allItems().find(i => i.id === id); if (x) openModal(x); }
function openModal(x) {
document.getElementById('modalMeta').textContent = `${x.id} · ${x.category} · ${x.narrative_label}`;
document.getElementById('modalTitle').textContent = x.title_cn;
const metrics = x.metrics ? `<div class="section"><h3>关键指标</h3><p>${Object.entries(x.metrics).map(([k,v]) => `${k}:${v}`).join(';')}</p></div>` : '';
document.getElementById('modalBody').innerHTML = `
<div class="detail-grid">
<div class="detail-cell"><span>风险等级</span><strong>${x.risk_level} · ${x.risk_score}</strong></div>
<div class="detail-cell"><span>叙事方向</span><strong>${x.narrative_label}</strong></div>
<div class="detail-cell"><span>时间</span><strong>${x.date || '持续'}</strong></div>
<div class="detail-cell"><span>地点</span><strong>${x.location || '—'}</strong></div>
<div class="detail-cell"><span>证据状态</span><strong>${x.evidence_status}</strong></div>
<div class="detail-cell"><span>可信度</span><strong>${x.confidence_level}</strong></div>
<div class="detail-cell"><span>类别</span><strong>${x.category}</strong></div>
<div class="detail-cell"><span>编号</span><strong>${x.id}</strong></div>
</div>
<div class="section"><h3>摘要</h3><p>${x.summary}</p></div>
<div class="section"><h3>详情解读</h3><p>${x.detail}</p></div>
${metrics}
<div class="section"><h3>关联标签</h3><p>${(x.tags || []).map(t => `#${t}`).join(' ')}</p></div>
<div class="section"><h3>展示建议</h3><p>该条目可与同类事件、相关人物组织、地方节点、法律制度和媒体平台样本联动。若用于二级页面,建议补充时间线、争议双方观点、原始语料和平台传播样本。</p></div>
`;
document.getElementById('modalBackdrop').classList.add('open');
}
function closeModal() { document.getElementById('modalBackdrop').classList.remove('open'); }
function backdropClose(e) { if (e.target.id === 'modalBackdrop') closeModal(); }
window.addEventListener('keydown', e => { if(e.key === 'Escape') closeModal(); });
init();
</script>
</body>
</html>
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>菲律宾“不做美国棋子”叙事专题数据库 v0.2</title>
<style>
:root{--bg:#f7f1ed;--card:#fffdfb;--ink:#291817;--muted:#7a6764;--line:#eadbd4;--red:#a23434;--red2:#6f1f1f;--blue:#244b74;--blue2:#eaf2fb;--gold:#9f6a1a;--green:#477052;--shadow:0 18px 50px rgba(51,20,14,.09)}
*{box-sizing:border-box} body{margin:0;background:linear-gradient(180deg,#f6eee9 0,#fbf7f3 100%);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",Arial,sans-serif;line-height:1.55}
header{padding:26px 30px 18px;background:linear-gradient(135deg,#8f2929,#b6473d 62%,#d8a16f);color:white;position:sticky;top:0;z-index:20;box-shadow:0 12px 30px rgba(76,17,17,.18)}
.topline{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;max-width:1500px;margin:0 auto}.title h1{font-size:28px;margin:0 0 6px}.title p{margin:0;opacity:.88}.tools{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.search{min-width:280px;border:0;border-radius:14px;padding:12px 14px;font-size:14px;outline:none}.btn,label.btn{border:1px solid rgba(255,255,255,.55);background:rgba(255,255,255,.14);color:white;border-radius:13px;padding:10px 13px;cursor:pointer;font-size:14px}label.btn input{display:none}
nav{background:#fff8f5;border-bottom:1px solid var(--line);position:sticky;top:105px;z-index:19}.tabs{display:flex;gap:8px;overflow:auto;padding:12px 28px;max-width:1500px;margin:0 auto}.tab{border:1px solid var(--line);background:#fff;border-radius:999px;padding:10px 15px;white-space:nowrap;cursor:pointer;color:var(--ink)}.tab.active{background:var(--red);color:white;border-color:var(--red)}
.wrap{display:grid;grid-template-columns:280px minmax(0,1fr) 260px;gap:18px;max-width:1500px;margin:18px auto;padding:0 24px 40px}.panel,.card,.modal{background:var(--card);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:22px}.filters{padding:16px;position:sticky;top:170px;align-self:start}.filters h3,.side h3{margin:0 0 12px}.field{margin:0 0 12px}.field label{display:block;color:var(--muted);font-size:12px;margin:0 0 5px}.field input,.field select{width:100%;border:1px solid var(--line);background:#fff;border-radius:12px;padding:10px 11px;outline:none}.field input:focus,.field select:focus{border-color:#ce9287}.reset{width:100%;border:1px solid var(--line);background:#fff;border-radius:12px;padding:10px;cursor:pointer}.side{padding:16px;position:sticky;top:170px;align-self:start}.summary-item{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius:14px;padding:10px 12px;margin:8px 0;background:#fff}.summary-item strong{font-size:19px;color:var(--red)}.summary-item span{font-size:13px;color:var(--muted)}
main{min-width:0}.section-title{display:flex;align-items:center;justify-content:space-between;gap:14px;margin:0 0 14px}.section-title h2{margin:0;font-size:24px}.section-title .sub{color:var(--muted);font-size:13px}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:16px}.stat{padding:18px;border-radius:20px;background:#fff;border:1px solid var(--line);box-shadow:var(--shadow)}.stat .num{font-size:32px;font-weight:800;color:var(--red)}.stat .lab{color:var(--muted)}.two-col{display:grid;grid-template-columns:1.15fr .85fr;gap:14px}.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{padding:16px;cursor:pointer;transition:.18s;min-height:176px}.card:hover{transform:translateY(-2px);box-shadow:0 22px 60px rgba(51,20,14,.15);border-color:#d7b9ac}.card h3{margin:8px 0 8px;font-size:18px}.card p{margin:0 0 10px;color:#5b4744}.meta-row{display:flex;gap:7px;align-items:center;flex-wrap:wrap}.pill{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--line);border-radius:999px;padding:4px 8px;font-size:12px;background:#fff;color:#5d4c49;margin:2px}.pill.red{background:#fff0ee;color:var(--red2);border-color:#ebc6bf}.pill.blue{background:var(--blue2);color:var(--blue);border-color:#cfe0ee}.pill.green{background:#eef8ef;color:var(--green);border-color:#cfe6d2}.pill.gold{background:#fff6e6;color:var(--gold);border-color:#ecd7ad}.risk-high{background:#fff0ee;color:#8b1d1d;border-color:#e8beb8}.risk-medium{background:#fff7e8;color:#8a5b08;border-color:#ead6a4}.risk-low{background:#eef9f1;color:#426f49;border-color:#cfe7d5}.small{font-size:12px;color:var(--muted)}.badge-title{font-size:12px;color:var(--muted);margin-top:12px;text-transform:uppercase;letter-spacing:.06em}.timeline{position:relative;padding-left:18px}.timeline:before{content:"";position:absolute;left:4px;top:6px;bottom:6px;width:2px;background:#e3c8c0}.titem{position:relative;margin:0 0 14px}.titem:before{content:"";position:absolute;left:-21px;top:20px;width:12px;height:12px;border-radius:50%;background:var(--red);box-shadow:0 0 0 5px #f8e8e4}.tcard{background:#fff;border:1px solid var(--line);border-radius:18px;padding:15px;cursor:pointer}.tdate{font-size:12px;color:var(--red);font-weight:700}.table-wrap{overflow:auto;background:#fff;border:1px solid var(--line);border-radius:18px}table{border-collapse:separate;border-spacing:0;width:100%;min-width:900px}th,td{border-bottom:1px solid var(--line);padding:12px 12px;text-align:left;vertical-align:top}th{background:#fff5f2;color:#6a3330;font-size:13px;position:sticky;top:0}td{font-size:14px}.viewbar{display:flex;gap:8px;justify-content:flex-end;margin:-4px 0 12px}.viewbar button, .inline-btn{border:1px solid var(--line);background:#fff;border-radius:999px;padding:8px 12px;cursor:pointer}.viewbar button.active{background:var(--red);color:#fff;border-color:var(--red)}.matrix{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.risk-col{border:1px solid var(--line);background:#fff;border-radius:20px;padding:14px}.empty{border:1px dashed #d7bdb5;border-radius:18px;padding:35px;text-align:center;color:var(--muted);background:#fff}.linkish,.rel-card{cursor:pointer}.rel-card{border:1px solid var(--line);border-radius:14px;padding:10px;background:#fff;margin:6px 0}.rel-card strong{display:block}.rel-card span{color:var(--muted);font-size:12px}.mini-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.distance-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.distance{background:#fff;border:1px solid var(--line);border-radius:16px;padding:12px}.distance strong{display:block;color:var(--red);font-size:22px}.distance span{font-size:12px;color:var(--muted)}
.modal-backdrop{position:fixed;inset:0;background:rgba(38,15,12,.5);display:none;align-items:center;justify-content:center;z-index:100;padding:20px}.modal-backdrop.show{display:flex}.modal{width:min(1120px,100%);max-height:90vh;overflow:auto}.modal-head{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:12px;align-items:flex-start;position:sticky;top:0;background:#fffdfb;z-index:2}.modal-title{margin:0;font-size:24px}.close{border:0;background:#f4e6e2;border-radius:12px;width:40px;height:40px;font-size:24px;cursor:pointer}.modal-body{padding:20px 24px 28px}.hero{padding:18px;border:1px solid var(--line);border-radius:20px;background:linear-gradient(135deg,#fff7f4,#fffdfb);margin-bottom:14px}.hero h3{margin:0 0 8px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.detail-section{border:1px solid var(--line);background:#fff;border-radius:18px;padding:15px;margin-bottom:14px}.detail-section h4{margin:0 0 10px;color:var(--red2);font-size:16px}.detail-section p{margin:6px 0}.detail-section ul{margin:8px 0 0;padding-left:20px}.obj-table{width:100%;min-width:0}.obj-table th,.obj-table td{font-size:13px;padding:8px}.full{grid-column:1/-1}.note-box{background:#fff9ef;border:1px solid #ecd5ad;border-radius:16px;padding:12px}.source-list{font-size:12px;color:var(--muted);line-height:1.6}.kpi-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:12px 0}.kpi{border:1px solid var(--line);border-radius:16px;padding:12px;background:#fff}.kpi b{display:block;color:var(--red);font-size:20px}.kpi span{font-size:12px;color:var(--muted)}
@media(max-width:1120px){.wrap{grid-template-columns:1fr}.filters,.side{position:relative;top:0}.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.two-col,.matrix,.detail-grid{grid-template-columns:1fr}nav{top:120px}.distance-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:680px){header{position:relative}.topline{display:block}.tools{margin-top:12px}.search{min-width:100%;width:100%}nav{position:relative;top:0}.wrap{padding:0 12px}.cards{grid-template-columns:1fr}.stats{grid-template-columns:1fr}.distance-grid,.mini-grid,.kpi-strip{grid-template-columns:1fr}.modal-backdrop{padding:6px}.modal-head{padding:16px}.modal-body{padding:14px}.tabs{padding:10px 12px}}
@media print{header,nav,.filters,.side,.tools,.modal-backdrop{display:none!important}.wrap{display:block;max-width:none}.card,.panel,.stat{box-shadow:none}.cards{grid-template-columns:1fr 1fr}}
</style>
</head>
<body>
<header><div class="topline"><div class="title"><h1 id="projectTitle">专题数据库</h1><p id="projectSubtitle"></p></div><div class="tools"><input id="globalSearch" class="search" placeholder="全局搜索:EDCA / Cagayan / BAYAN / 台海 / WPS"><label class="btn">导入JSON<input id="jsonFile" type="file" accept="application/json,.json"></label><button class="btn" onclick="downloadJson()">导出JSON</button></div></div></header>
<nav><div class="tabs" id="tabs"></div></nav>
<div class="wrap"><aside class="panel filters"><h3>筛选器</h3><div class="field"><label>关键词</label><input id="filterKeyword" placeholder="在当前库内搜索"></div><div class="field"><label>年份</label><select id="filterYear"></select></div><div class="field"><label>事件类型</label><select id="filterCategory"></select></div><div class="field"><label>省份</label><select id="filterProvince"></select></div><div class="field"><label>阵营</label><select id="filterCamp"></select></div><div class="field"><label>风险等级</label><select id="filterRisk"></select></div><div class="field"><label>战略方向</label><select id="filterAxis"></select></div><button class="reset" onclick="resetFilters()">清空筛选</button><div class="source-list" style="margin-top:14px">说明:本页面为公开资料研究展示工具,复杂字段以详情弹框结构化展示,不再输出原始JSON。</div></aside><main id="main"></main><aside class="panel side"><h3>数据摘要</h3><div id="summaryPanel"></div><h3 style="margin-top:18px">高频标签</h3><div id="tagCloud"></div></aside></div>
<div class="modal-backdrop" id="modalBackdrop" onclick="backdropClose(event)"><div class="modal"><div class="modal-head"><div><h2 id="modalTitle" class="modal-title"></h2><div id="modalMeta" class="meta-row"></div></div><button class="close" onclick="closeModal()">×</button></div><div id="modalBody" class="modal-body"></div></div></div>
<script id="db-json" type="application/json">{"meta": {"project_title": "菲律宾“不做美国棋子”叙事专题数据库", "project_subtitle": "Philippines “Not a US Pawn” Narrative Research Dashboard", "version": "0.2", "last_updated": "2026-06-29", "language": "zh-CN", "description": "用于展示菲律宾“菲律宾不做美国棋子”叙事专题的结构化知识库,覆盖EDCA地点、事件线、人物组织、叙事结构、竞争叙事、风险核查和跨库关联查询。", "scope_note": "本数据库服务于公开资料研究、舆情观察、风险评估与专题分析,不用于隐蔽影响、虚假传播或操控性动员。", "revision_note": "v0.2增强版:弹框详情改为结构化页面展示,补充研究字段,尽量保留前期MD知识库中的分析内容。"}, "tabs": [{"id": "overview", "label": "专题总览", "type": "dashboard"}, {"id": "timeline", "label": "事件时间线", "type": "timeline"}, {"id": "edca_sites", "label": "EDCA地点库", "type": "cards_table"}, {"id": "actors", "label": "人物组织库", "type": "cards_table"}, {"id": "narratives", "label": "叙事结构库", "type": "cards"}, {"id": "counter_narratives", "label": "竞争叙事库", "type": "cards"}, {"id": "risks", "label": "风险核查库", "type": "risk_matrix"}, {"id": "search", "label": "综合查询", "type": "search"}], "dictionaries": {"event_categories": {"edca_signature": "EDCA签署/制度化", "edca_expansion": "EDCA扩展", "edca_infrastructure": "EDCA基建", "balikatan": "Balikatan军演", "china_coast_guard": "中国海警事件", "ph_official_protest": "菲律宾官方抗议", "us_ph_meeting": "美菲高层会晤", "anti_edca_protest": "反EDCA/反军演抗议", "multilateral_security": "多边安全合作", "wps_civil_action": "WPS民间行动"}, "strategic_axis": {"taiwan_bashi": "台海—巴士海峡方向", "wps_scs": "西菲律宾海—南海方向", "northern_luzon": "北吕宋前沿方向", "central_luzon": "中吕宋支撑方向", "central_logistics": "中部群岛后勤方向", "mindanao": "棉兰老方向", "hadr": "人道援助与灾害响应"}, "actor_camps": {"marcos_official": "马科斯官方阵营", "duterte_camp": "杜特尔特阵营", "left_anti_base": "左翼反基地阵营", "wps_sovereignty": "WPS主权派", "fact_check": "事实核查机构", "security_expert": "安全专家与智库", "external_actor": "外部行为体"}, "actor_types": {"official_person": "官方人物", "political_person": "政治人物", "organization": "组织", "party_bloc": "政党/党团", "institution": "机构", "expert": "专家", "think_tank": "智库", "media_factcheck": "事实核查/媒体"}, "stance_values": {"support": "支持", "oppose": "反对", "mixed": "复杂/混合", "neutral": "中立", "unknown": "未知"}, "risk_levels": {"low": "低", "medium": "中", "high": "高"}, "site_types": {"naval_base": "海军基地", "airport": "机场", "army_camp": "陆军营地", "air_base": "空军基地", "airfield": "机场/航空场"}, "field_labels": {"id": "编号", "name": "名称", "name_cn": "中文名", "name_en": "英文名", "title": "标题", "summary": "摘要", "brief": "简述", "description": "说明", "province": "省份", "city_or_area": "城市/区域", "designation_year": "纳入年份", "site_type": "地点类型", "strategic_axis": "战略方向", "risk_level": "风险等级", "local_position": "地方态度", "tags": "标签", "distances": "近似距离", "detail": "详情", "strategic_value": "战略意义", "local_political_positions": "地方政治人物表态", "local_reactions": "地方居民抗议或支持", "infrastructure_projects": "基建项目", "risk_controversies": "风险争议", "official_response": "官方回应", "related_events": "关联事件", "related_actors": "关联人物组织", "related_sites": "关联地点", "background": "背景", "opposition_position": "反对方立场", "ph_official_position": "菲律宾官方立场", "us_position": "美国立场", "china_position": "中国立场", "research_note": "研究备注", "category": "类别", "date": "日期", "year": "年份", "location": "地点", "actors": "相关行为体", "narrative_signal": "叙事信号", "counter_narrative": "竞争叙事/反驳框架", "current_role": "当前身份", "camp": "阵营", "type": "类型", "stance_on_edca": "对EDCA立场", "stance_on_wps": "对WPS立场", "influence_level": "影响等级", "narrative_function": "叙事功能", "typical_positions": "典型立场", "typical_keywords": "典型关键词", "risk_note": "风险备注", "profile": "画像", "monitoring_focus": "监测重点", "core_claims": "核心命题", "target_audiences": "目标受众", "typical_expressions": "典型表达", "strength": "叙事强项", "weakness": "叙事弱项", "counter_narratives": "竞争叙事", "main_speakers": "主要提出者", "used_against": "用于反驳", "evidence_types": "证据类型", "advantage": "优势", "limitation": "限制", "trigLine truncated
<script>
let DB = JSON.parse(document.getElementById('db-json').textContent);
const $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
let state={tab:'overview',view:'cards',filters:{},global:''};
const collMap={event:'events',edca_site:'edca_sites',actor:'actors',narrative:'narratives',counter_narrative:'counter_narratives',risk:'risks'};
const tabsForType={event:'timeline',edca_site:'edca_sites',actor:'actors',narrative:'narratives',counter_narrative:'counter_narratives',risk:'risks'};
function arr(x){return Array.isArray(x)?x:[]}
function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
function dict(group,key){return DB.dictionaries?.[group]?.[key] || key || ''}
function label(k){return DB.dictionaries?.field_labels?.[k] || k}
function titleOf(o){return o?.title || o?.name_cn || o?.name || o?.name_en || o?.id || ''}
function summaryOf(o){return o?.summary || o?.brief || o?.description || o?.narrative_function || ''}
function risk(level){return level?`<span class="pill risk-${esc(level)}">风险:${esc(dict('risk_levels',level))}</span>`:''}
function chips(tags,cls=''){return arr(tags).map(t=>`<span class="pill ${cls}">${esc(t)}</span>`).join('')}
function getAll(){let out=[];Object.entries(collMap).forEach(([type,key])=>arr(DB.data?.[key]).forEach(x=>out.push({...x,__type:type})));return out}
function find(id){for(const [type,key] of Object.entries(collMap)){const x=arr(DB.data?.[key]).find(i=>i.id===id); if(x) return {...x,__type:type}}return null}
function searchText(o){return JSON.stringify(o).toLowerCase()}
function matchKw(o,kw){return !kw || searchText(o).includes(kw.toLowerCase())}
function setup(){ $('#projectTitle').textContent=DB.meta.project_title; $('#projectSubtitle').textContent=(DB.meta.project_subtitle||'')+' · '+(DB.meta.revision_note||''); renderTabs(); fillFilters(); bind(); render(); }
function renderTabs(){ $('#tabs').innerHTML=arr(DB.tabs).map(t=>`<button class="tab ${state.tab===t.id?'active':''}" data-id="${esc(t.id)}">${esc(t.label)}</button>`).join(''); $$('#tabs .tab').forEach(b=>b.onclick=()=>{state.tab=b.dataset.id;renderTabs();render();}); }
function optAll(label){return `<option value="">${label}</option>`}
function fillFilters(){ const years=[...new Set(arr(DB.data.events).map(e=>e.year).filter(Boolean))].sort((a,b)=>a-b); $('#filterYear').innerHTML=optAll('全部年份')+years.map(y=>`<option>${y}</option>`).join(''); $('#filterCategory').innerHTML=optAll('全部类型')+Object.entries(DB.dictionaries.event_categories||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); const ps=[...new Set(arr(DB.data.edca_sites).map(s=>s.province).filter(Boolean))].sort(); $('#filterProvince').innerHTML=optAll('全部省份')+ps.map(p=>`<option>${esc(p)}</option>`).join(''); $('#filterCamp').innerHTML=optAll('全部阵营')+Object.entries(DB.dictionaries.actor_camps||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); $('#filterRisk').innerHTML=optAll('全部风险')+Object.entries(DB.dictionaries.risk_levels||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); $('#filterAxis').innerHTML=optAll('全部方向')+Object.entries(DB.dictionaries.strategic_axis||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); }
function bind(){ ['filterKeyword','filterYear','filterCategory','filterProvince','filterCamp','filterRisk','filterAxis'].forEach(id=>{$('#'+id).oninput=collect;$('#'+id).onchange=collect}); $('#globalSearch').oninput=e=>{state.global=e.target.value.trim(); if(state.global){state.tab='search';renderTabs()} render()}; $('#jsonFile').onchange=importJson; window.onkeydown=e=>{if(e.key==='Escape')closeModal()}; }
function collect(){ state.filters={keyword:$('#filterKeyword').value.trim(),year:$('#filterYear').value,category:$('#filterCategory').value,province:$('#filterProvince').value,camp:$('#filterCamp').value,risk:$('#filterRisk').value,axis:$('#filterAxis').value}; render(); }
function resetFilters(){['filterKeyword','filterYear','filterCategory','filterProvince','filterCamp','filterRisk','filterAxis','globalSearch'].forEach(id=>$('#'+id).value='');state.filters={};state.global='';render()}
function commonFilter(items){const f=state.filters||{};return items.filter(x=>{if(f.keyword&&!matchKw(x,f.keyword))return false;if(f.risk&&x.risk_level!==f.risk)return false;if(f.year&&x.year&&String(x.year)!==String(f.year))return false;if(f.category&&x.category&&x.category!==f.category)return false;if(f.province&&x.province&&x.province!==f.province)return false;if(f.camp&&x.camp&&x.camp!==f.camp)return false;if(f.axis&&x.strategic_axis&&!arr(x.strategic_axis).includes(f.axis))return false;return true})}
function render(){renderSummary();const t=state.tab;if(t==='overview')overview();if(t==='timeline')timeline();if(t==='edca_sites')sites();if(t==='actors')actors();if(t==='narratives')narratives();if(t==='counter_narratives')counters();if(t==='risks')risks();if(t==='search')search();}
function renderSummary(){const d=DB.data;const rows=[['EDCA地点',arr(d.edca_sites).length],['事件',arr(d.events).length],['人物组织',arr(d.actors).length],['叙事结构',arr(d.narratives).length],['竞争叙事',arr(d.counter_narratives).length],['风险项',arr(d.risks).length],['高风险地点',arr(d.edca_sites).filter(x=>x.risk_level==='high').length],['高风险事件',arr(d.events).filter(x=>x.risk_level==='high').length]];$('#summaryPanel').innerHTML=rows.map(([k,v])=>`<div class="summary-item"><strong>${v}</strong><span>${k}</span></div>`).join(''); const tags={}; getAll().forEach(x=>arr(x.tags).forEach(t=>tags[t]=(tags[t]||0)+1)); $('#tagCloud').innerHTML=Object.entries(tags).sort((a,b)=>b[1]-a[1]).slice(0,20).map(([t,n])=>`<span class="pill">${esc(t)} · ${n}</span>`).join('');}
function overview(){const d=DB.data;const high=arr(d.edca_sites).filter(x=>x.risk_level==='high');const eventCats={};arr(d.events).forEach(e=>eventCats[e.category]=(eventCats[e.category]||0)+1); const camps={};arr(d.actors).forEach(a=>camps[a.camp]=(camps[a.camp]||0)+1); $('#main').innerHTML=`<div class="section-title"><div><h2>专题总览</h2><div class="sub">${esc(DB.meta.description)}</div></div><span class="pill red">${esc(DB.meta.version)} · ${esc(DB.meta.last_updated)}</span></div><div class="stats"><div class="stat"><div class="num">${arr(d.edca_sites).length}</div><div class="lab">EDCA地点</div></div><div class="stat"><div class="num">${arr(d.events).length}</div><div class="lab">事件线节点</div></div><div class="stat"><div class="num">${arr(d.actors).length}</div><div class="lab">人物组织</div></div><div class="stat"><div class="num">${high.length}</div><div class="lab">高风险地点</div></div></div><div class="two-col"><div class="panel" style="padding:18px"><h3>核心问题</h3><p>本页面围绕“菲律宾不做美国棋子 / not a US pawn”这一叙事,展示其与EDCA、美菲同盟、Balikatan军演、西菲律宾海主权、杜特尔特—马科斯政治竞争、地方风险和事实核查之间的关系。</p><p class="small">${esc(DB.meta.scope_note||'')}</p>${chips(DB.indexes?.common_tags)}</div><div class="panel" style="padding:18px"><h3>事件类型分布</h3>${Object.entries(eventCats).map(([k,v])=>`<div class="summary-item"><strong>${v}</strong><span>${esc(dict('event_categories',k))}</span></div>`).join('')}</div></div><div style="height:14px"></div><div class="two-col"><div class="panel" style="padding:18px"><h3>阵营分布</h3>${Object.entries(camps).map(([k,v])=>`<div class="summary-item"><strong>${v}</strong><span>${esc(dict('actor_camps',k))}</span></div>`).join('')}</div><div class="panel" style="padding:18px"><h3>展示原则</h3><ul><li>卡片只看摘要,弹框展示完整知识结构。</li><li>长字段按模块呈现,不再显示原始JSON。</li><li>所有关联对象支持点击跳转。</li><li>风险内容同时展示核查重点和边界。</li></ul></div></div><div style="height:14px"></div><div class="section-title"><h2>高风险EDCA地点</h2><button class="inline-btn" onclick="state.tab='edca_sites';renderTabs();render()">查看全部</button></div><div class="cards">${high.map(siteCard).join('')}</div>`}
function timeline(){let items=commonFilter(arr(DB.data.events)).sort((a,b)=>String(b.date).localeCompare(String(a.date))); if(state.filters.province){const ids=arr(DB.data.edca_sites).filter(s=>s.province===state.filters.province).map(s=>s.id);items=items.filter(e=>arr(e.related_sites).some(id=>ids.includes(id))||matchKw(e,state.filters.province));} $('#main').innerHTML=`<div class="section-title"><h2>事件时间线</h2><span class="pill blue">命中 ${items.length} 条</span></div>`+(items.length?`<div class="timeline">${items.map(e=>`<div class="titem"><div class="tcard" onclick="openModal('event','${esc(e.id)}')"><div class="tdate">${esc(e.date)} · ${esc(dict('event_categories',e.category))}</div><h3>${esc(e.title)}</h3><p>${esc(e.summary)}</p><div class="meta-row">${risk(e.risk_level)}${chips(e.tags)}</div></div></div>`).join('')}</div>`:empty())}
function sites(){let items=commonFilter(arr(DB.data.edca_sites)); $('#main').innerHTML=`<div class="section-title"><h2>EDCA九个地点库</h2><span class="pill blue">命中 ${items.length} 条</span></div>${viewbar('sites')} ${state.view==='table'?siteTable(items):`<div class="cards">${items.map(siteCard).join('')}</div>`}`}
function siteCard(s){return `<div class="card" onclick="openModal('edca_site','${esc(s.id)}')"><div class="meta-row"><span class="pill red">${esc(s.province)}</span>${risk(s.risk_level)}</div><h3>${esc(s.name)}</h3><p>${esc(s.brief)}</p><div class="badge-title">战略方向</div><div>${arr(s.strategic_axis).map(a=>`<span class="pill blue">${esc(dict('strategic_axis',a))}</span>`).join('')}</div><div class="badge-title">标签</div>${chips(s.tags)}</div>`}
function siteTable(items){return `<div class="table-wrap"><table><thead><tr><th>地点</th><th>省份</th><th>年份</th><th>类型</th><th>战略方向</th><th>地方态度</th><th>风险</th><th>详情</th></tr></thead><tbody>${items.map(s=>`<tr><td><b>${esc(s.name)}</b><br><span class="small">${esc(s.city_or_area)}</span></td><td>${esc(s.province)}</td><td>${esc(s.designation_year)}</td><td>${esc(dict('site_types',s.site_type))}</td><td>${arr(s.strategic_axis).map(a=>esc(dict('strategic_axis',a))).join('<br>')}</td><td>${esc(s.local_position)}</td><td>${risk(s.risk_level)}</td><td><button class="inline-btn" onclick="openModal('edca_site','${esc(s.id)}')">打开</button></td></tr>`).join('')}</tbody></table></div>`}
function actors(){let items=commonFilter(arr(DB.data.actors)); $('#main').innerHTML=`<div class="section-title"><h2>人物组织库</h2><span class="pill blue">命中 ${items.length} 条</span></div>${viewbar('actors')} ${state.view==='table'?actorTable(items):`<div class="cards">${items.map(actorCard).join('')}</div>`}`}
function actorCard(a){return `<div class="card" onclick="openModal('actor','${esc(a.id)}')"><div class="meta-row"><span class="pill red">${esc(dict('actor_camps',a.camp))}</span><span class="pill">${esc(dict('actor_types',a.type))}</span></div><h3>${esc(a.name_cn||a.name_en)}</h3><p>${esc(a.brief)}</p><div class="badge-title">叙事功能</div><p>${esc(a.narrative_function||'')}</p>${chips(a.tags)}</div>`}
function actorTable(items){return `<div class="table-wrap"><table><thead><tr><th>名称</th><th>类型</th><th>阵营</th><th>当前角色</th><th>EDCA立场</th><th>WPS立场</th><th>影响</th><th>详情</th></tr></thead><tbody>${items.map(a=>`<tr><td><b>${esc(a.name_cn||a.name_en)}</b><br><span class="small">${esc(a.name_en||'')}</span></td><td>${esc(dict('actor_types',a.type))}</td><td>${esc(dict('actor_camps',a.camp))}</td><td>${esc(a.current_role)}</td><td>${esc(dict('stance_values',a.stance_on_edca))}</td><td>${esc(dict('stance_values',a.stance_on_wps))}</td><td>${esc(a.influence_level)}</td><td><button class="inline-btn" onclick="openModal('actor','${esc(a.id)}')">打开</button></td></tr>`).join('')}</tbody></table></div>`}
function narratives(){const items=commonFilter(arr(DB.data.narratives)); $('#main').innerHTML=`<div class="section-title"><h2>叙事结构库</h2><span class="pill blue">命中 ${items.length} 条</span></div><div class="cards">${items.map(n=>`<div class="card" onclick="openModal('narrative','${esc(n.id)}')"><div class="meta-row"><span class="pill red">${esc(n.category)}</span>${risk(n.risk_level)}</div><h3>${esc(n.title)}</h3><p>${esc(n.summary)}</p>${chips(n.tags)}</div>`).join('')}</div>`}
function counters(){const items=commonFilter(arr(DB.data.counter_narratives)); $('#main').innerHTML=`<div class="section-title"><h2>竞争叙事库</h2><span class="pill blue">命中 ${items.length} 条</span></div><div class="cards">${items.map(c=>`<div class="card" onclick="openModal('counter_narrative','${esc(c.id)}')"><div class="meta-row"><span class="pill blue">竞争叙事</span>${risk(c.risk_level)}</div><h3>${esc(c.title)}</h3><p>${esc(c.summary)}</p>${chips(c.tags)}</div>`).join('')}</div>`}
function risks(){const items=commonFilter(arr(DB.data.risks)); const levels=['high','medium','low']; $('#main').innerHTML=`<div class="section-title"><h2>风险与事实核查库</h2><span class="pill blue">命中 ${items.length} 条</span></div><div class="matrix">${levels.map(l=>`<div class="risk-col"><h3>${esc(dict('risk_levels',l))}风险</h3>${items.filter(r=>r.risk_level===l).map(r=>`<div class="card" style="min-height:auto;margin-bottom:10px" onclick="openModal('risk','${esc(r.id)}')"><h3>${esc(r.title)}</h3><p>${esc(r.description)}</p>${chips(r.tags)}</div>`).join('')||'<div class="empty">暂无</div>'}</div>`).join('')}</div>`}
function search(){const kw=state.global||state.filters.keyword||'';let items=getAll().filter(x=>matchKw(x,kw));items=commonFilter(items); const names={event:'事件',edca_site:'EDCA地点',actor:'人物组织',narrative:'叙事结构',counter_narrative:'竞争叙事',risk:'风险'}; $('#main').innerHTML=`<div class="section-title"><div><h2>综合查询中心</h2><div class="sub">${kw?'关键词:'+esc(kw):'可在顶部或左侧输入关键词,跨库检索所有对象。'}</div></div><span class="pill blue">命中 ${items.length} 条</span></div>`+(items.length?`<div class="table-wrap"><table><thead><tr><th>类型</th><th>标题/名称</th><th>摘要</th><th>标签</th><th>风险</th><th>详情</th></tr></thead><tbody>${items.map(i=>`<tr><td><span class="pill red">${esc(names[i.__type])}</span></td><td><b>${esc(titleOf(i))}</b></td><td>${esc(summaryOf(i)).slice(0,260)}</td><td>${chips(i.tags)}</td><td>${risk(i.risk_level)}</td><td><button class="inline-btn" onclick="openModal('${esc(i.__type)}','${esc(i.id)}')">打开</button></td></tr>`).join('')}</tbody></table></div>`:empty('暂无查询结果。'))}
function viewbar(){return `<div class="viewbar"><button class="${state.view==='cards'?'active':''}" onclick="state.view='cards';render()">卡片</button><button class="${state.view==='table'?'active':''}" onclick="state.view='table';render()">表格</button></div>`}
function empty(t='没有匹配结果。'){return `<div class="empty">${esc(t)}</div>`}
function openModal(type,id){const item=find(id); if(!item)return; $('#modalTitle').textContent=titleOf(item); $('#modalMeta').innerHTML=modalMeta(item); $('#modalBody').innerHTML=modalBody(item,type); $('#modalBackdrop').classList.add('show')}
function closeModal(){$('#modalBackdrop').classList.remove('show')}
function backdropClose(e){if(e.target.id==='modalBackdrop')closeModal()}
function modalMeta(o){let bits=[]; if(o.date)bits.push(`<span class="pill">${esc(o.date)}</span>`); if(o.category)bits.push(`<span class="pill blue">${esc(dict('event_categories',o.category))}</span>`); if(o.province)bits.push(`<span class="pill red">${esc(o.province)}</span>`); if(o.camp)bits.push(`<span class="pill red">${esc(dict('actor_camps',o.camp))}</span>`); if(o.risk_level)bits.push(risk(o.risk_level)); return bits.join('')+chips(o.tags)}
function section(title,content,cls=''){if(!content)return''; return `<section class="detail-section ${cls}"><h4>${esc(title)}</h4>${content}</section>`}
function paragraph(v){return v?`<p>${esc(v)}</p>`:''}
function list(v){if(!v||!arr(v).length)return''; return `<ul>${arr(v).map(x=>`<li>${renderVal(x)}</li>`).join('')}</ul>`}
function objGrid(o){if(!o||typeof o!=='object')return''; return `<div class="mini-grid">${Object.entries(o).filter(([k,v])=>v!==undefined&&v!==null&&v!==''&&!(Array.isArray(v)&&!v.length)).map(([k,v])=>`<div class="rel-card"><span>${esc(label(k))}</span><strong>${renderVal(v)}</strong></div>`).join('')}</div>`}
function objTable(items){if(!arr(items).length)return''; if(typeof items[0]!=='object')return list(items); const keys=[...new Set(items.flatMap(o=>Object.keys(o)))]; return `<div class="table-wrap"><table class="obj-table"><thead><tr>${keys.map(k=>`<th>${esc(label(k))}</th>`).join('')}</tr></thead><tbody>${items.map(o=>`<tr>${keys.map(k=>`<td>${renderVal(o[k])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`}
function renderVal(v){if(v===undefined||v===null)return''; if(Array.isArray(v)){if(v.length&&typeof v[0]==='object')return objTable(v); return arr(v).map(x=>renderVal(x)).join('<br>')} if(typeof v==='object')return objGrid(v); const s=String(v); const x=find(s); if(x)return `<span class="linkish pill blue" onclick="jump('${esc(s)}')">${esc(titleOf(x))}</span>`; return esc(s)}
function rels(ids){return arr(ids).map(id=>{const x=find(id); return x?`<div class="rel-card" onclick="jump('${esc(id)}')"><strong>${esc(titleOf(x))}</strong><span>${esc(typeName(x.__type))} · ${esc(summaryOf(x)).slice(0,88)}</span></div>`:`<div class="rel-card"><strong>${esc(id)}</strong></div>`}).join('')}
function typeName(t){return {event:'事件',edca_site:'EDCA地点',actor:'人物组织',narrative:'叙事结构',counter_narrative:'竞争叙事',risk:'风险'}[t]||t}
function distances(d){if(!d)return''; return `<div class="distance-grid">${Object.entries(d).map(([k,v])=>`<div class="distance"><strong>${esc(v)} km</strong><span>${esc(label(k))}</span></div>`).join('')}</div>`}
function modalBody(o,type){
let d=o.detail||{};
if(type==='edca_site') return `<div class="hero"><h3>${esc(o.name)}</h3><p>${esc(o.brief)}</p><div class="kpi-strip"><div class="kpi"><b>${esc(o.designation_year)}</b><span>纳入年份</span></div><div class="kpi"><b>${esc(o.province)}</b><span>省份</span></div><div class="kpi"><b>${esc(dict('site_types',o.site_type))}</b><span>地点类型</span></div><div class="kpi"><b>${esc(dict('risk_levels',o.risk_level))}</b><span>风险等级</span></div></div></div><div class="detail-grid">${section('基本信息',objGrid({province:o.province,city_or_area:o.city_or_area,designation_year:o.designation_year,site_type:dict('site_types',o.site_type),strategic_axis:arr(o.strategic_axis).map(a=>dict('strategic_axis',a)).join(' / '),local_position:o.local_position}),'full')}${section('近似距离与战略方向',distances(o.distances),'full')}${section('战略意义',paragraph(d.strategic_value))}${section('叙事解读',list(d.narrative_interpretation))}${section('地方政治人物表态',objTable(d.local_political_positions),'full')}${section('地方居民抗议或支持',objGrid(d.local_reactions))}${section('基建项目',list(d.infrastructure_projects))}${section('风险争议',list(d.risk_controversies))}${section('官方回应',paragraph(d.official_response))}${section('竞争性解释',list(d.counter_reading))}${section('证据线索',list(d.evidence_clues))}${section('展示提示',`<div class="note-box">${esc(d.display_warning||'')}</div>`)}${section('研究缺口',list(d.research_gaps),'full')}${section('关联事件',rels(d.related_events),'full')}${section('关联人物组织',rels(d.related_actors),'full')}</div>`;
if(type==='event') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.summary)}</p></div><div class="detail-grid">${section('时间地点',objGrid({date:o.date,year:o.year,location:o.location,category:dict('event_categories',o.category)}),'full')}${section('事件背景',paragraph(d.background))}${section('为什么重要',paragraph(d.why_it_matters||o.narrative_signal))}${section('叙事信号',paragraph(o.narrative_signal))}${section('竞争叙事/反驳框架',paragraph(o.counter_narrative))}${section('菲律宾官方立场',paragraph(d.ph_official_position))}${section('美国立场',paragraph(d.us_position))}${section('中国立场',paragraph(d.china_position))}${section('反对方立场',paragraph(d.opposition_position))}${section('叙事复杂性',paragraph(d.narrative_complication))}${section('研究备注',paragraph(d.research_note),'full')}${section('相关行为体',rels(o.actors),'full')}${section('关联地点',rels(o.related_sites),'full')}</div>`;
if(type==='actor') return `<div class="hero"><h3>${esc(o.name_cn||o.name_en)}</h3><p>${esc(o.brief)}</p></div><div class="detail-grid">${section('基本信息',objGrid({name_en:o.name_en,type:dict('actor_types',o.type),camp:dict('actor_camps',o.camp),current_role:o.current_role,stance_on_edca:dict('stance_values',o.stance_on_edca),stance_on_wps:dict('stance_values',o.stance_on_wps),influence_level:o.influence_level}),'full')}${section('人物/组织画像',paragraph(d.profile))}${section('叙事功能',paragraph(o.narrative_function))}${section('典型立场',list(d.typical_positions))}${section('典型关键词',list(d.typical_keywords))}${section('监测重点',list(d.monitoring_focus),'full')}${section('风险备注',paragraph(d.risk_note))}${section('研究备注',paragraph(d.research_note))}${section('关联事件',rels(d.related_events),'full')}${section('关联地点',rels(d.related_sites),'full')}</div>`;
if(type==='narrative') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.summary)}</p></div><div class="detail-grid">${section('核心命题',list(o.core_claims),'full')}${section('目标受众',list(o.target_audiences))}${section('典型表达',list(o.typical_expressions))}${section('适用场景',list(d.application_scenarios))}${section('叙事强项',paragraph(d.strength))}${section('叙事弱项',paragraph(d.weakness))}${section('分析备注',paragraph(d.analysis_note),'full')}${section('待补数据',list(d.data_to_collect))}${section('关联事件',rels(o.related_events),'full')}${section('关联人物组织',rels(o.related_actors),'full')}${section('竞争叙事',rels(d.counter_narratives),'full')}</div>`;
if(type==='counter_narrative') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.summary)}</p></div><div class="detail-grid">${section('主要提出者',list(o.main_speakers))}${section('证据类型',list(o.evidence_types))}${section('优势',paragraph(d.advantage))}${section('限制',paragraph(d.limitation))}${section('观察场景',list(d.where_to_observe))}${section('对“美国棋子”叙事的影响',paragraph(d.impact_on_proxy_narrative),'full')}${section('用于反驳',rels(o.used_against),'full')}${section('关联事件',rels(d.related_events),'full')}</div>`;
if(type==='risk') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.description)}</p></div><div class="detail-grid">${section('触发场景',list(o.trigger_scenarios),'full')}${section('事实核查重点',list(o.fact_check_focus),'full')}${section('风险边界与处理建议',list(o.mitigation_guidelines),'full')}${section('展示提示',`<div class="note-box">${esc(o.display_note||'')}</div>`)}${section('关联地点',rels(o.related_sites),'full')}${section('关联人物组织',rels(o.related_actors),'full')}</div>`;
return section('详情',renderVal(o),'full')
}
function jump(id){const x=find(id); if(!x)return; closeModal(); state.tab=tabsForType[x.__type]||'search'; renderTabs(); render(); setTimeout(()=>openModal(x.__type,id),80)}
function importJson(e){const file=e.target.files[0]; if(!file)return; const r=new FileReader(); r.onload=ev=>{try{DB=JSON.parse(ev.target.result);fillFilters();renderTabs();render();alert('JSON导入成功')}catch(err){alert('JSON解析失败:'+err.message)}}; r.readAsText(file,'utf-8')}
function downloadJson(){const blob=new Blob([JSON.stringify(DB,null,2)],{type:'application/json;charset=utf-8'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download='philippines_not_us_pawn_narrative_db_v2_export.json';a.click();URL.revokeObjectURL(url)}
setup();
</script>
</body>
</html>
@@ -12,7 +12,8 @@ Resolution rule (per resource type):
* For a resource type **with** bound tags → the union of resources carrying any
of those tags.
* For a resource type **without** bound tags → the *default set*:
- ``agent`` → all built-in agents (``user_id IS NULL``)
- ``agent`` -> built-ins first, then filtered out of
position-granted favorites
- ``scheduled_task`` → empty (no built-in tasks)
- ``skill`` → empty (the 广场 still shows every visible skill;
a position only *adds* its tagged skills to the member's "我的技能")
@@ -24,9 +25,12 @@ Resolution rule (per resource type):
Materialization (``sync_user``) only ever touches rows it created itself
(``origin='position'``), so a user's own favorites / subscriptions are never
clobbered. ``skill`` is materialized into ``skill_favorites`` exactly like
``agent`` → ``agent_favorites``: the tagged skills are *added* to the user's
"我的技能" while the browse-all 广场 stays complete (it is **not** restricted).
clobbered. ``agent`` keeps the original position-granted behavior for ordinary
tagged agents, but filters out built-ins and business-chain seats; those only
enter "mine" after an explicit user favorite. ``skill`` is still materialized
into ``skill_favorites`` so tagged skills are added to the user's "mine"
collection while the browse-all square stays complete (it is **not**
restricted).
Only recommended-question visibility is computed live by :meth:`resolve_visible`
and is *not* materialized. (The admin-configured prompts shown to users ARE the
recommended questions — there is no separate prompt-template store.)
@@ -34,6 +38,7 @@ recommended questions — there is no separate prompt-template store.)
from __future__ import annotations
import json
import logging
import uuid
from datetime import UTC, datetime
@@ -43,13 +48,15 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.positions.base import PositionStore
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskSubscriptionRow
from deerflow.persistence.skills.model import SkillFavoriteRow
from deerflow.persistence.tags.base import TagStore
logger = logging.getLogger(__name__)
# Materialized into "我的" tables by sync_user.
# Reconciled by sync_user. ``agent`` preserves ordinary position-granted rows,
# but filters built-ins and business-chain seats.
_MATERIALIZED_TYPES = ("agent", "skill", "scheduled_task")
# Resolved live at query time (visibility filters), never materialized.
_VISIBILITY_TYPES = ("recommended_question",)
@@ -93,8 +100,12 @@ class PositionSyncService:
bindings = await self._positions.get_tag_bindings(pid) if pid else {}
tag_ids = bindings.get(resource_type) or []
if tag_ids:
return set(await self._tags.list_targets_for_tags(resource_type, tag_ids))
return await self._default_set(resource_type, session)
target = set(await self._tags.list_targets_for_tags(resource_type, tag_ids))
else:
target = await self._default_set(resource_type, session)
if resource_type == "agent":
return await self._filter_materializable_agents(target, session)
return target
async def _default_set(self, resource_type: str, session: AsyncSession) -> set[str]:
if resource_type == "agent":
@@ -103,6 +114,37 @@ class PositionSyncService:
# scheduled_task (and any other materialized type) defaults to empty.
return set()
async def _filter_materializable_agents(self, agent_ids: set[str], session: AsyncSession) -> set[str]:
if not agent_ids:
return set()
rows = (
await session.execute(
select(AgentRow.id).where(
AgentRow.id.in_(agent_ids),
AgentRow.user_id.is_not(None),
)
)
).all()
candidate_ids = {r[0] for r in rows}
return candidate_ids - await self._business_chain_agent_ids(session)
async def _business_chain_agent_ids(self, session: AsyncSession) -> set[str]:
rows = (await session.execute(select(RoundtableChainRow.seats))).scalars()
agent_ids: set[str] = set()
for raw in rows:
if not raw:
continue
try:
seats = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
continue
if not isinstance(seats, list):
continue
for seat in seats:
if isinstance(seat, dict) and isinstance(seat.get("agent_id"), str):
agent_ids.add(seat["agent_id"])
return agent_ids
# --------------------------------------------------------- materialize
async def sync_user(self, user_id: str) -> None:
@@ -1,33 +1,131 @@
from __future__ import annotations
import pytest
import yaml
from app.gateway.routers.agents import (
_backfill_listing_cache,
_load_config_extras,
_write_agent_config,
)
from app.gateway.services import build_run_config
from deerflow.config import paths as paths_module
from deerflow.config.agents_config import AgentConfig
from deerflow.persistence.agents import MemoryAgentStore
from __future__ import annotations
import json
from uuid import uuid4
import pytest
import pytest_asyncio
import yaml
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers.agents import (
_backfill_listing_cache,
_load_config_extras,
_write_agent_config,
)
from app.gateway.services import build_run_config
from deerflow.config import paths as paths_module
from deerflow.config.agents_config import AgentConfig
from deerflow.persistence.agents import AgentRepository, MemoryAgentStore
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.base import Base
from deerflow.persistence.positions.memory import MemoryPositionStore
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.tags.memory import MemoryTagStore
from deerflow.services.position_sync import PositionSyncService
@pytest.fixture
def isolated_paths(tmp_path, monkeypatch):
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr(paths_module, "_paths", None)
yield tmp_path
monkeypatch.setattr(paths_module, "_paths", None)
def isolated_paths(tmp_path, monkeypatch):
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr(paths_module, "_paths", None)
yield tmp_path
monkeypatch.setattr(paths_module, "_paths", None)
@pytest_asyncio.fixture
async def sql_session_factory(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'agent-tests.db'}")
async with engine.begin() as conn:
# Importing PositionSyncService above registers the agent / skill /
# scheduled-task rows it materializes into.
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
try:
yield sf
finally:
await engine.dispose()
async def _add_sql_agent(sf, *, agent_id: str, user_id: str | None) -> None:
async with sf() as session:
session.add(
AgentRow(
id=agent_id,
name=agent_id,
description="",
user_id=user_id,
published=True,
is_private=False,
square_id="",
)
)
await session.commit()
async def _add_sql_favorite(sf, *, user_id: str, agent_id: str, origin: str) -> None:
async with sf() as session:
session.add(
AgentFavoriteRow(
id=uuid4().hex,
user_id=user_id,
agent_id=agent_id,
origin=origin,
position_id="default" if origin == "position" else None,
)
)
await session.commit()
async def _add_sql_chain(sf, *, chain_id: str, agent_ids: list[str]) -> None:
async with sf() as session:
session.add(
RoundtableChainRow(
id=chain_id,
user_id="chain-owner",
title=chain_id,
description="",
seats=json.dumps(
[{"agent_id": agent_id, "name": agent_id, "description": ""} for agent_id in agent_ids],
ensure_ascii=False,
),
)
)
await session.commit()
async def _sql_favorite_rows(sf, user_id: str) -> set[tuple[str, str]]:
async with sf() as session:
rows = (
await session.execute(
select(AgentFavoriteRow.agent_id, AgentFavoriteRow.origin).where(
AgentFavoriteRow.user_id == user_id
)
)
).all()
return {(agent_id, origin) for agent_id, origin in rows}
async def _default_positions() -> MemoryPositionStore:
positions = MemoryPositionStore()
await positions.create_position(
{
"id": "default",
"name": "Default",
"description": "",
"is_default": True,
}
)
return positions
@pytest.mark.asyncio
async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None:
store = MemoryAgentStore()
chinese_name = "\u4e2d\u6587\u52a9\u624b"
async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None:
store = MemoryAgentStore()
chinese_name = "\u4e2d\u6587\u52a9\u624b"
await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"})
await store.create({"id": "private-a", "name": chinese_name, "description": "private", "user_id": "user-a"})
await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True})
@@ -35,14 +133,144 @@ async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility
user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")}
assert user_a_ids == {"builtin-agent", "private-a", "published-b"}
user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")}
assert user_c_ids == {"builtin-agent", "published-b"}
@pytest.mark.asyncio
async def test_agent_store_only_owner_can_update_or_delete() -> None:
store = MemoryAgentStore()
user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")}
assert user_c_ids == {"builtin-agent", "published-b"}
@pytest.mark.asyncio
async def test_position_sync_default_does_not_collect_builtin_agents(sql_session_factory) -> None:
await _add_sql_agent(sql_session_factory, agent_id="builtin-auto", user_id=None)
await _add_sql_agent(sql_session_factory, agent_id="builtin-manual", user_id=None)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="builtin-auto",
origin="position",
)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="builtin-manual",
origin="user",
)
sync = PositionSyncService(
sql_session_factory,
await _default_positions(),
MemoryTagStore(),
)
await sync.sync_user("user-a")
assert await _sql_favorite_rows(sql_session_factory, "user-a") == {
("builtin-manual", "user")
}
@pytest.mark.asyncio
async def test_position_sync_tagged_agents_are_not_auto_favorited(sql_session_factory) -> None:
await _add_sql_agent(sql_session_factory, agent_id="builtin-tagged", user_id=None)
await _add_sql_agent(sql_session_factory, agent_id="custom-tagged", user_id="owner")
await _add_sql_agent(sql_session_factory, agent_id="chain-tagged", user_id="owner")
await _add_sql_chain(sql_session_factory, chain_id="chain-1", agent_ids=["chain-tagged"])
positions = await _default_positions()
tags = MemoryTagStore()
await tags.create_tag({"id": "agent-tag", "name": "Agent tag", "tag_type": "agent"})
await tags.set_target_tags("agent", "builtin-tagged", ["agent-tag"])
await tags.set_target_tags("agent", "custom-tagged", ["agent-tag"])
await tags.set_target_tags("agent", "chain-tagged", ["agent-tag"])
await positions.set_tag_bindings("default", {"agent": ["agent-tag"]})
sync = PositionSyncService(sql_session_factory, positions, tags)
await sync.sync_user("user-a")
assert await _sql_favorite_rows(sql_session_factory, "user-a") == {
("custom-tagged", "position")
}
@pytest.mark.asyncio
async def test_mine_scope_ignores_auto_agent_favorites_but_keeps_manual_builtin(sql_session_factory) -> None:
await _add_sql_agent(sql_session_factory, agent_id="builtin-auto", user_id=None)
await _add_sql_agent(sql_session_factory, agent_id="builtin-manual", user_id=None)
await _add_sql_agent(sql_session_factory, agent_id="custom-auto", user_id="owner")
await _add_sql_agent(sql_session_factory, agent_id="chain-auto", user_id="owner")
await _add_sql_chain(sql_session_factory, chain_id="chain-1", agent_ids=["chain-auto"])
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="builtin-auto",
origin="position",
)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="builtin-manual",
origin="user",
)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="custom-auto",
origin="position",
)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="chain-auto",
origin="position",
)
store = AgentRepository(sql_session_factory)
rows, total = await store.list_paginated(
user_id="user-a",
scope="mine",
search=None,
tag_ids=None,
page=1,
page_size=20,
)
assert total == 2
assert {row["id"] for row in rows} == {"builtin-manual", "custom-auto"}
assert await store.list_favorite_ids("user-a") == {"builtin-manual", "custom-auto"}
@pytest.mark.asyncio
async def test_manual_favorite_upgrades_existing_auto_builtin_favorite(sql_session_factory) -> None:
await _add_sql_agent(sql_session_factory, agent_id="builtin-agent", user_id=None)
await _add_sql_favorite(
sql_session_factory,
user_id="user-a",
agent_id="builtin-agent",
origin="position",
)
store = AgentRepository(sql_session_factory)
assert await store.list_favorite_ids("user-a") == set()
await store.add_favorite("user-a", "builtin-agent")
assert await _sql_favorite_rows(sql_session_factory, "user-a") == {
("builtin-agent", "user")
}
assert await store.list_favorite_ids("user-a") == {"builtin-agent"}
rows, total = await store.list_paginated(
user_id="user-a",
scope="mine",
search=None,
tag_ids=None,
page=1,
page_size=20,
)
assert total == 1
assert [row["id"] for row in rows] == ["builtin-agent"]
@pytest.mark.asyncio
async def test_agent_store_only_owner_can_update_or_delete() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"})
@@ -14,7 +14,9 @@ from fastapi import UploadFile
from app.gateway.routers.ai_writing import _extract_sample_text
from deerflow.agents.ai_writing.nodes import researcher as R
from deerflow.agents.ai_writing.nodes import sample_analyzer as S
from deerflow.agents.ai_writing.search import get_search_provider
from deerflow.agents.ai_writing.search.base import SearchResult
from deerflow.agents.ai_writing.search.intranet_stub import IntranetSearchProvider
class _FakeIntranet:
@@ -23,16 +25,34 @@ class _FakeIntranet:
def __init__(self, **kw):
self.errors = []
self.raw_responses = []
self.calls = []
self.kw = kw
self.verify_ssl = kw.get("verify_ssl", False)
_FakeIntranet.instances.append(self)
async def search(self, query, max_results=5, **_):
self.calls.append({"query": query, "status": "ok", "ok": True, "count": 1, "elapsed_ms": 12})
return [SearchResult(
title=f"内网-{query}", content="内网正文", source="ES",
published_at="", url=f"http://es/{query}", source_type="intranet",
)]
class _EmptyIntranet(_FakeIntranet):
async def search(self, query, max_results=5, **_):
self.calls.append({"query": query, "status": "empty", "ok": True, "count": 0, "elapsed_ms": 9})
return []
class _FailingIntranet(_FakeIntranet):
async def search(self, query, max_results=5, **_):
msg = f"ConnectError: cannot connect for {query}"
self.errors.append(msg)
self.raw_responses.append("{\"success\": false, \"error\": \"cannot connect\"}")
self.calls.append({"query": query, "status": "error", "ok": False, "count": 0, "elapsed_ms": 3, "error": msg})
return []
class _SkillProviderShouldNotRun:
"""knowledge_base 模式不应触碰通用 provider —— 调到就让测试爆。"""
errors: list = []
@@ -42,29 +62,41 @@ class _SkillProviderShouldNotRun:
raise AssertionError("knowledge_base 模式不应调用通用 provider / 技能检索")
def _patch(monkeypatch, intranet_url="http://es/api"):
class _FakeResponse:
status_code = 200
text = '[{"title":"内网标题","content":"内网正文","url":"https://intranet/doc"}]'
def raise_for_status(self) -> None:
return None
def json(self):
return [{"title": "内网标题", "content": "内网正文", "url": "https://intranet/doc"}]
def _patch(monkeypatch, intranet_url="http://es/api", intranet_cls=_FakeIntranet, intranet_verify_ssl=False):
_FakeIntranet.instances = []
cfg = {
"max_materials": 20,
"intranet_search_url": intranet_url,
"intranet_knowledge_base": "samples",
"intranet_verify_ssl": intranet_verify_ssl,
}
monkeypatch.setattr(R, "get_ai_writing_config", lambda: cfg)
monkeypatch.setattr(R, "use_builtin_agent", lambda *_: False)
monkeypatch.setattr(R, "safe_get_stream_writer", lambda: (lambda *a, **k: None))
monkeypatch.setattr(R, "use_skill_search_agent", lambda: False)
async def fake_llm_json(*a, **k):
async def fake_stream_llm_json(*a, **k):
return {"keywords": ["特朗普竞选"]}
monkeypatch.setattr(R, "llm_json", fake_llm_json)
monkeypatch.setattr(R, "_stream_llm_json", fake_stream_llm_json)
# knowledge_base 模式不该加载/编排技能,但仍打桩以防意外路径
monkeypatch.setattr(R, "_load_configured_skills",
lambda: [SimpleNamespace(name="knowledge-search-v2", description="内网检索")])
monkeypatch.setattr(R, "get_search_provider", lambda _cfg: _SkillProviderShouldNotRun())
monkeypatch.setattr(
"deerflow.agents.ai_writing.search.intranet_stub.IntranetSearchProvider",
_FakeIntranet,
intranet_cls,
)
@@ -89,6 +121,14 @@ async def test_knowledge_base_hits_intranet_directly(monkeypatch):
assert _FakeIntranet.instances, "应实例化 IntranetSearchProvider 直连内网接口"
# 走素材确认暂停点
assert out["status"] == "awaiting_material_confirm"
dbg = out["material_package"].get("intranet_debug")
assert dbg["mode"] == "knowledge_base"
assert dbg["status"] == "ok"
assert dbg["endpoint"] == "http://es/api"
assert dbg["result_count"] == 1
assert dbg["queries"][0]["query"] == "特朗普竞选"
assert dbg["queries"][0]["count"] == 1
assert dbg["calls"][0]["status"] == "ok"
@pytest.mark.asyncio
@@ -104,6 +144,15 @@ async def test_knowledge_base_no_url_falls_back_to_default_endpoint(monkeypatch)
assert _FakeIntranet.instances[0].kw.get("base_url") == "", "空地址应原样下传,由 provider 回退默认端点"
@pytest.mark.asyncio
async def test_knowledge_base_passes_verify_ssl_config(monkeypatch):
_patch(monkeypatch, intranet_verify_ssl=True)
out = await R.researcher_node(_state(), {"configurable": {}})
assert _FakeIntranet.instances[0].kw.get("verify_ssl") is True
assert out["material_package"]["intranet_debug"]["verify_ssl"] is True
@pytest.mark.asyncio
async def test_knowledge_base_does_not_orchestrate_skills(monkeypatch):
"""knowledge_base 不编排技能:通用 provider 一旦被调用就会 raise。"""
@@ -122,7 +171,7 @@ async def test_sample_analyzer_skips_normal_mode():
@pytest.mark.asyncio
async def test_sample_analyzer_builds_style_profile(monkeypatch):
async def fake_llm_json(*_args, **_kwargs):
async def fake_stream_sample_analysis(*_args, **_kwargs):
return {
"summary": "短段落、评论式表达",
"tone": "克制、坚定",
@@ -136,7 +185,7 @@ async def test_sample_analyzer_builds_style_profile(monkeypatch):
"sample_outline": ["问题提出", "原因分析"],
}
monkeypatch.setattr(S, "llm_json", fake_llm_json)
monkeypatch.setattr(S, "_stream_sample_analysis", fake_stream_sample_analysis)
out = await S.sample_analyzer_node({
"writing_mode_type": "imitate",
@@ -183,3 +232,75 @@ async def test_extract_sample_text_reads_txt_upload():
assert name == "sample.txt"
assert text == "样文内容"
@pytest.mark.asyncio
async def test_knowledge_base_empty_result_is_visible(monkeypatch):
"""接口成功但 0 条也要在返回里能看出来,而不是只显示"无素材"。"""
_patch(monkeypatch, intranet_cls=_EmptyIntranet)
out = await R.researcher_node(_state(), {"configurable": {}})
pkg = out["material_package"]
assert pkg["materials"] == []
dbg = pkg.get("intranet_debug")
assert dbg["status"] == "empty"
assert dbg["result_count"] == 0
assert dbg["queries"][0]["count"] == 0
assert dbg["calls"][0]["status"] == "empty"
steps = pkg["skill_search_steps"]
assert any(step["type"] == "skill_result" and "0 条" in step["message"] for step in steps)
@pytest.mark.asyncio
async def test_knowledge_base_failure_is_visible(monkeypatch):
"""provider 吞掉异常返回 [] 时,前端仍应看到报错步骤与 notice。"""
_patch(monkeypatch, intranet_cls=_FailingIntranet)
out = await R.researcher_node(_state(), {"configurable": {}})
pkg = out["material_package"]
dbg = pkg.get("intranet_debug")
assert dbg["status"] == "error"
assert dbg["errors"]
assert dbg["raw_responses"]
steps = pkg["skill_search_steps"]
assert any(step["type"] == "skill_error" for step in steps)
assert any(event["type"] == "notice" and event.get("raw") for event in out["progress_events"])
@pytest.mark.asyncio
async def test_intranet_search_disables_ssl_verification_for_internal_https(monkeypatch):
import httpx
seen: dict = {}
class _FakeAsyncClient:
def __init__(self, **kwargs):
seen.update(kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, endpoint, json):
seen["endpoint"] = endpoint
seen["payload"] = json
return _FakeResponse()
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
provider = IntranetSearchProvider(base_url="https://intranet.example/search", knowledge_base_name="kb", timeout=300)
results = await provider.search("测试", max_results=1)
assert results
assert seen["verify"] is False
assert seen["trust_env"] is False
assert seen["endpoint"] == "https://intranet.example/search"
assert seen["payload"]["knowledge_base_name"] == "kb"
assert provider.calls[0]["verify_ssl"] is False
def test_intranet_verify_ssl_can_be_reenabled_from_config():
provider = get_search_provider({"search_provider": "intranet", "intranet_verify_ssl": "true"})
assert isinstance(provider, IntranetSearchProvider)
assert provider.verify_ssl is True
@@ -0,0 +1,199 @@
"""Tests for per-model concurrency limit settings and enforcement."""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from app.gateway.routers import system_settings as ss_router
from app.gateway.services import (
_get_model_concurrency_limit,
_resolve_effective_run_model_name,
)
from deerflow.config import system_settings as ss
from deerflow.runtime.runs.manager import ModelConcurrencyLimitError, RunManager
@pytest.fixture
def tmp_runtime_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
def fake_home() -> Path:
return tmp_path
monkeypatch.setattr(ss, "runtime_home", fake_home)
return tmp_path
def _patch_admin(monkeypatch: pytest.MonkeyPatch, user: object | None = None) -> None:
admin = user or SimpleNamespace(
id="admin1",
system_role="admin",
email="admin@example.com",
)
async def _optional(_request):
return admin
monkeypatch.setattr(ss_router, "get_optional_user_from_request", _optional)
@pytest.mark.asyncio
async def test_model_concurrency_settings_round_trip(
tmp_runtime_home: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_admin(monkeypatch)
req = SimpleNamespace()
saved = await ss_router.update_model_concurrency(
ss_router.ModelConcurrencyUpdateRequest(
enabled=True,
default_limit=8,
model_limits={"glm5": 10, "deepseek": 4},
),
req,
)
assert saved.enabled is True
assert saved.default_limit == 8
assert saved.model_limits == {"glm5": 10, "deepseek": 4}
file_path = tmp_runtime_home / "system_settings.json"
payload = json.loads(file_path.read_text(encoding="utf-8"))
assert payload["model_concurrency"]["enabled"] is True
assert payload["model_concurrency"]["default_limit"] == 8
assert payload["model_concurrency"]["model_limits"]["glm5"] == 10
loaded = await ss_router.get_model_concurrency(req)
assert loaded.enabled is True
assert loaded.default_limit == 8
assert loaded.model_limits["deepseek"] == 4
def test_resolve_effective_run_model_name_prefers_requested_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app_config = SimpleNamespace(
models=[SimpleNamespace(name="default-model")],
get_model_config=lambda name: {"name": name} if name in {"default-model", "glm5", "agent-model"} else None,
)
monkeypatch.setattr("app.gateway.services.get_app_config", lambda: app_config)
monkeypatch.setattr(
"app.gateway.services.load_agent_config",
lambda _agent_id: SimpleNamespace(model="agent-model"),
)
body = SimpleNamespace(
assistant_id="custom-agent",
context={"model_name": "glm5"},
config=None,
)
assert _resolve_effective_run_model_name(body) == "glm5"
def test_resolve_effective_run_model_name_uses_agent_default_when_request_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app_config = SimpleNamespace(
models=[SimpleNamespace(name="default-model")],
get_model_config=lambda name: {"name": name} if name in {"default-model", "agent-model"} else None,
)
monkeypatch.setattr("app.gateway.services.get_app_config", lambda: app_config)
monkeypatch.setattr(
"app.gateway.services.load_agent_config",
lambda _agent_id: SimpleNamespace(model="agent-model"),
)
body = SimpleNamespace(
assistant_id="custom-agent",
context={},
config=None,
)
assert _resolve_effective_run_model_name(body) == "agent-model"
def test_resolve_effective_run_model_name_uses_context_agent_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app_config = SimpleNamespace(
models=[SimpleNamespace(name="default-model")],
get_model_config=lambda name: {"name": name} if name in {"default-model", "agent-model"} else None,
)
monkeypatch.setattr("app.gateway.services.get_app_config", lambda: app_config)
monkeypatch.setattr(
"app.gateway.services.load_agent_config",
lambda agent_id: SimpleNamespace(model="agent-model") if agent_id == "custom-agent" else SimpleNamespace(model="default-model"),
)
body = SimpleNamespace(
assistant_id="lead_agent",
context={"agent_id": "custom-agent"},
config=None,
)
assert _resolve_effective_run_model_name(body) == "agent-model"
def test_resolve_effective_run_model_name_context_agent_overrides_assistant_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app_config = SimpleNamespace(
models=[SimpleNamespace(name="default-model")],
get_model_config=lambda name: {"name": name} if name in {"default-model", "context-model", "assistant-model"} else None,
)
monkeypatch.setattr("app.gateway.services.get_app_config", lambda: app_config)
monkeypatch.setattr(
"app.gateway.services.load_agent_config",
lambda agent_id: SimpleNamespace(model="context-model" if agent_id == "context-agent" else "assistant-model"),
)
body = SimpleNamespace(
assistant_id="assistant-agent",
context={"agent_id": "context-agent"},
config=None,
)
assert _resolve_effective_run_model_name(body) == "context-model"
@pytest.mark.asyncio
async def test_run_manager_rejects_model_limit_atomically(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"app.gateway.services.load_system_settings",
lambda: SimpleNamespace(
model_concurrency=SimpleNamespace(
enabled=True,
default_limit=5,
model_limits={"glm5": 2},
)
),
)
assert _get_model_concurrency_limit("glm5") == 2
run_mgr = RunManager()
await run_mgr.create_or_reject("t1", metadata={"model_name": "glm5"})
await run_mgr.create_or_reject("t2", metadata={"model_name": "glm5"})
with pytest.raises(ModelConcurrencyLimitError):
await run_mgr.create_or_reject(
"t3",
metadata={"model_name": "glm5"},
model_concurrency_limit=("glm5", 2),
)
def test_get_model_concurrency_limit_skips_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"app.gateway.services.load_system_settings",
lambda: SimpleNamespace(
model_concurrency=SimpleNamespace(
enabled=False,
default_limit=1,
model_limits={"glm5": 1},
)
),
)
assert _get_model_concurrency_limit("glm5") is None
@@ -0,0 +1,166 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from deerflow.agents.middlewares.title_middleware import (
TitleMiddleware,
finalize_thread_title,
)
class _FakeCheckpointer:
def __init__(self, checkpoint: dict, metadata: dict | None = None):
self._tuple = SimpleNamespace(
checkpoint=checkpoint,
metadata=metadata or {},
config={"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}},
)
self.aput_calls: list[tuple[dict, dict, dict, dict]] = []
self._version = 0
async def aget_tuple(self, _config):
return self._tuple
def get_next_version(self, _current, _channel):
self._version += 1
return self._version
async def aput(self, config, checkpoint, metadata, versions):
self.aput_calls.append((config, checkpoint, metadata, versions))
self._tuple = SimpleNamespace(
checkpoint=checkpoint,
metadata=metadata,
config={
"configurable": {
"thread_id": config["configurable"]["thread_id"],
"checkpoint_ns": config["configurable"]["checkpoint_ns"],
"checkpoint_id": "new-ckpt",
}
},
)
return self._tuple.config
class _FakeThreadStore:
def __init__(self):
self.updated: list[tuple[str, str, str | None]] = []
async def update_display_name(self, thread_id: str, title: str, *, user_id=None):
self.updated.append((thread_id, title, user_id))
def _source_state() -> dict:
return {
"messages": [
SimpleNamespace(type="human", content="please analyze the first topic"),
SimpleNamespace(type="ai", content="this is the first answer"),
],
"title": "please analyze the first topic",
"title_provisional": True,
}
def _dict_source_state() -> dict:
return {
"messages": [
{"type": "human", "content": "please analyze the first topic"},
{"type": "ai", "content": "this is the first answer"},
],
"title": "please analyze the first topic",
"title_provisional": True,
}
@pytest.mark.asyncio
async def test_finalize_thread_title_updates_checkpoint_and_display_name(monkeypatch) -> None:
async def _fake_generate(self, state):
assert state["title_provisional"] is True
return {"title": "first topic analysis", "title_provisional": False}
monkeypatch.setattr(TitleMiddleware, "_agenerate_title_result", _fake_generate)
checkpointer = _FakeCheckpointer({"channel_values": _source_state()})
thread_store = _FakeThreadStore()
updated = await finalize_thread_title(
checkpointer=checkpointer,
thread_store=thread_store,
thread_id="thread-1",
source_state=_source_state(),
)
assert updated is True
assert len(checkpointer.aput_calls) == 1
_, written_checkpoint, _, new_versions = checkpointer.aput_calls[0]
assert written_checkpoint["channel_values"]["title"] == "first topic analysis"
assert written_checkpoint["channel_values"]["title_provisional"] is False
assert set(new_versions) == {"title", "title_provisional"}
assert thread_store.updated == [("thread-1", "first topic analysis", None)]
@pytest.mark.asyncio
async def test_finalize_thread_title_skips_if_current_thread_already_finalized(monkeypatch) -> None:
async def _fake_generate(self, state):
return {"title": "first topic analysis", "title_provisional": False}
monkeypatch.setattr(TitleMiddleware, "_agenerate_title_result", _fake_generate)
checkpointer = _FakeCheckpointer(
{
"channel_values": {
**_source_state(),
"title": "manually renamed title",
"title_provisional": False,
}
}
)
thread_store = _FakeThreadStore()
updated = await finalize_thread_title(
checkpointer=checkpointer,
thread_store=thread_store,
thread_id="thread-1",
source_state=_source_state(),
)
assert updated is False
assert checkpointer.aput_calls == []
assert thread_store.updated == []
def test_sync_after_model_keeps_provisional_title_for_background_finalize() -> None:
middleware = TitleMiddleware()
result = middleware.after_model(_source_state(), runtime=SimpleNamespace())
assert result is None
@pytest.mark.asyncio
async def test_async_after_model_keeps_provisional_title_for_background_finalize() -> None:
middleware = TitleMiddleware()
result = await middleware.aafter_model(_source_state(), runtime=SimpleNamespace())
assert result is None
@pytest.mark.asyncio
async def test_finalize_thread_title_accepts_serialized_dict_messages(monkeypatch) -> None:
async def _fake_generate(self, state):
return {"title": "first topic analysis", "title_provisional": False}
monkeypatch.setattr(TitleMiddleware, "_agenerate_title_result", _fake_generate)
checkpointer = _FakeCheckpointer({"channel_values": _dict_source_state()})
updated = await finalize_thread_title(
checkpointer=checkpointer,
thread_store=None,
thread_id="thread-1",
source_state=_dict_source_state(),
)
assert updated is True
+221
View File
@@ -0,0 +1,221 @@
# xdfx 单技能全流程使用说明
## 适用场景
使用 `xdfx-single-agent-full-flow` 技能,让单智能体一次性完成 xdfx 深链接模式下的完整分析与入库流程:
1. 受众目标分析,输出 `用户协助` 让用户选择受众,入库选中的受众。
2. 关键传播节点分析,跨受众去重后输出 `用户协助` 让用户选择节点,入库选中的节点。
3. 从已关联关键传播节点的受众中生成脆弱点候选,输出 `用户协助` 让用户选择后入库。
4. 从已入库关键传播节点中生成脆弱点候选,输出 `用户协助` 让用户选择后入库。
5. 让用户选择一个或多个受众生成策略并入库。
6. 让用户选择一个或多个关键传播节点生成策略并入库;受众和节点不能在同一步交叉。
7. 同步前端步骤状态,保证原 `item-aiceh` 页面步骤条可继续识别。
策略入库归属由步骤强制决定:Step5 固定写“目标受众账号”和 `designId + strategyId` 关系,Step6 固定写“关键传播节点账号”和 `pathwayId + strategyId` 关系,不用模型返回的 `accountType` 判断归属。
## 技能位置
```text
skills-data/xdfx-single-agent-full-flow/
```
核心逻辑在:
```text
skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py
```
后续使用时不要让智能体照着说明手写接口调用,必须让它直接执行这个 Python 脚本。
## 输入文件
在技能目录下准备 `xdfx-input.json`:
```json
{
"api_base": "https://ch1.b.uat.4.cn",
"taskId": "真实任务id",
"actionId": "真实xdfx行动id",
"mubiao": "完整行动目标,例如:传播某叙事,驱动某行为体产生某预期行为,实现某作战目的。",
"direction": "U方向",
"platform": "fb"
}
```
默认会自动处理登录态:脚本启动时先 `import scripts/xdfx_auth.py`,用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/auth/login`,请求体为 `{"username":"admin","password":"ch@user"}`。拿到 `access_token` 后写入内部 `Authorization: Bearer ...`,再调用第一步业务接口;不要通过任何命令行参数传 token。
默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;如果部署环境证书链正常,可以追加 `--verify-ssl`。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次。
## 执行命令
进入技能目录后按步骤执行。这个版本仍然是 `xdfx-single-agent-full-flow` 一个技能负责完整流程,但不要默认使用一个 `--mode full` 长命令;为了保证用户能看到实时反馈,智能体要一条命令跑一步,每步结束后先反馈给用户,再继续下一步:
```bash
python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
python scripts/xdfx_flow.py --mode step2 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
python scripts/xdfx_flow.py --mode step4 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
`--mode full` 只作为无人值守或排障兜底,不作为默认执行方式;外层工具通常会等长命令结束后才返回输出,无法保证真实逐步反馈。逐步命令共享同一个 `xdfx-flow-state.json`,不会影响 id 传递、断点续跑和最终入库。
如果需要一条命令完成逐步编排,可以使用包装脚本。它内部仍按 step1-step6 调用 `xdfx_flow.py`,并逐行转发反馈:
```bash
python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
每完成一步都会输出一行:
```text
步骤反馈 {...}
```
调用技能的智能体必须在每个 step 命令结束后读取 `步骤反馈`,给用户一次反馈,再执行下一条 step 命令。
每次执行具体操作或调用接口前,脚本会输出:
```text
操作反馈 {...}
```
调用技能的智能体必须把 `操作`、`请求方式` 和 `接口地址` 转述给用户,例如:
```text
正在保存受众目标,调用 POST https://.../api/task/task-action-design。
```
每次保存接口成功拿到 `id` 后,脚本会输出:
```text
入库反馈 {...}
```
调用技能的智能体必须把 `入库对象类型`、`入库对象名称` 和 `返回ID` 转述给用户,例如“已入库受众目标:xxx,designId=123”或“已入库受众脆弱点:xxx,id=456”。
如果报错,脚本会立即输出:
```text
错误反馈 {...}
```
调用技能的智能体必须立刻告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果模型 JSON 经过自动提取和修复仍无法解析,脚本会输出:
```text
JSON解析复核 {...}
```
调用技能的智能体必须查看 `响应片段`,判断是否能识别真正 JSON;无法确认时重跑当前步骤或重新调用对应模型接口。
只检查脚本与参数、不调用真实接口:
```bash
python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run
```
如果只想入库数据,不更新前端步骤条:
```bash
python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --no-step-status
python scripts/xdfx_flow.py --mode step2 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --no-step-status
python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --no-step-status
python scripts/xdfx_flow.py --mode step4 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --no-step-status
python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --no-step-status
python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md --no-step-status
```
## 入库内容
脚本会写入以下接口对应的数据:
- `task-action-design`
- `task-action-design-account`
- `task-action-pathway-account`
- `task-action-design-pathway-relation/saveRelation`
- `task-action-design-vulns`
- `task-action-pathway-account-vulns`
- `task-action-strategy`
- `task-action-channel`
- `base-narrate`
- `task-action-message`
- `task-action-design-strategy-relation`
- `task/task-action/{actionId}` 的 `step` 字段
策略信息形式/模态会按前端 `modelType` 字典保存为 value 逗号串,例如模型输出“图片,视频”时,脚本先查 `sys/sys-dict-data` 再写入 `task-action-message.modelType`,不会直接保存中文文字。
策略关系表写入时,Step5 只写受众 `designId`,Step6 只写传播节点 `pathwayId`;即使模型把 `账号类型/accountType` 生成反了,脚本也会按当前步骤强制纠正,避免受众策略和传播节点策略串写。
## 输出文件
执行后查看:
```text
xdfx-flow-state.json
xdfx-flow-summary.md
```
`xdfx-flow-state.json` 保存断点状态、接口返回 ID、模型解析结果与错误明细。
`xdfx-flow-summary.md` 是给用户看的执行摘要。
## 每步反馈
脚本会输出六类反馈行:
- `操作反馈`:当前正在做什么、调用哪个接口。
- `用户协助`:当前步骤需要用户选择候选;智能体必须暂停后续步骤,展示选项,等待用户选择后重跑同一个 step。
- `入库反馈`:当前已入库的对象类型、名称和返回 ID。
- `错误反馈`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
- `JSON解析复核`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
- `步骤反馈`:当前步骤最终状态和累计入库数量。
`步骤反馈` 中包含:
- `步骤`:步骤编号。
- `步骤名称`:步骤名称。
- `状态`:`成功`、`部分完成`、`等待用户选择` 或 `已跳过`。
- `执行状态`:中文执行状态。
- `累计入库数量`:当前累计入库数量。
- `本步骤新增错误数`:本步骤新增错误数。
- `累计错误数`:累计错误数。
智能体反馈用户时建议使用这种格式:
```text
Step1 受众目标分析完成:当前已入库受众 X 个,本步骤新增错误 Y 个。
```
如果 `状态` 为 `部分完成`,要补一句“该步骤部分完成”,并说明稍后最终汇总会列出错误明细。
如果 `状态` 为 `已跳过`,要说明跳过原因。
## 容错规则
- 单个受众、账号、传播节点、脆弱点或策略失败,不中断整个流程。
- 保存接口必须返回 `id`,否则不会进入下游步骤。
- 受众目标默认入库 3 个,关键传播节点默认入库 3 个。
- 默认开启用户协助选择:Step1 选择受众;Step2 选择关键传播节点;Step3/Step4 选择要入库的脆弱点;Step5 只能选择多个受众生成策略;Step6 只能选择多个关键传播节点生成策略。受众和关键传播节点不能在同一步交叉生成策略。
- 用户选择后重跑当前 step:`--select-audiences`、`--select-pathways`、`--select-audience-vulns`、`--select-pathway-vulns`、`--select-strategy-audiences`、`--select-strategy-pathways`。
- 下游受众脆弱点/策略只能从“已在 Step2 分析出并关联关键传播节点”的受众中选择,默认取前 2 个;没有产生传播节点的受众不得继续分析脆弱点和策略。
- 下游传播节点脆弱点/策略默认只选择前 2 个已入库传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
- 每个被选中的对象默认最多生成 3 个脆弱点、3 个策略。
- 模型流式 `answer` 会自动拼接。
- 流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 模型输出会自动去掉 `<think>`,从前后混杂文本、多个 JSON 代码块、无换行流式文本、连续 JSON 对象、非工整 JSON 中提取多个候选,并按当前步骤所需字段选择最像的 JSON。
- 模型 JSON 会尝试修复中文引号、全角冒号/逗号、尾随逗号、未加引号的键、Python 风格 `True/False/None`。
- 模型 JSON 解析失败会自动重试 1 次。
- 每一步完成都会写入 state/summary 并输出 `步骤反馈`。
- 接口失败会写入 `errors`,包含阶段、URL、状态码、请求摘要、响应正文和异常类型。
## 回复用户规则
执行结束后,智能体必须读取 `xdfx-flow-summary.md` 和 `xdfx-flow-state.json.errors` 再回复用户。
如果 `errors` 为空,可以说明“全流程完成”。
如果 `errors` 非空,只能说明“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more