Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-dev
# Conflicts: # frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx # frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx # offline-backend-20260512/backend/CLAUDE.md # offline-backend-20260512/backend/app/gateway/services.py # offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py
This commit is contained in:
commit
221e947488
138 files changed
+65056
-2405
No files matched your search
@@ -14,8 +14,8 @@ VITE_MOCK_SQ_REPORT=false
|
||||
VITE_MOCK_COP_TASK_DETAIL=false
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
|
||||
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
|
||||
VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
|
||||
VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
@@ -144,18 +145,13 @@ export const CodeBlockCopyButton = ({
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronsUpDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
ListTree,
|
||||
MousePointerClick,
|
||||
Settings,
|
||||
@@ -231,6 +232,14 @@ function SecondaryNavSidebar() {
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||||
>
|
||||
<Gauge className="size-4" />
|
||||
模型并发限制
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
|
||||
|
||||
@@ -260,37 +260,68 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
|
||||
key={agent.id}
|
||||
href={href}
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors",
|
||||
"group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 transition-all duration-150",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-transparent hover:border-border hover:bg-accent/50",
|
||||
? isDark
|
||||
? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]"
|
||||
: "border-primary/50 bg-primary/10 shadow-sm"
|
||||
: isDark
|
||||
? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60"
|
||||
: "border-transparent hover:border-border hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
|
||||
className={cn(
|
||||
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
|
||||
isDark &&
|
||||
isActive &&
|
||||
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
|
||||
}}
|
||||
>
|
||||
<Bot className="size-[18px]" />
|
||||
<Bot className="size-[19px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[13px] font-semibold",
|
||||
isActive ? "text-primary" : "text-foreground",
|
||||
"block truncate text-[16px] leading-5 font-bold",
|
||||
isActive
|
||||
? isDark
|
||||
? "text-white"
|
||||
: "text-primary"
|
||||
: isDark
|
||||
? "text-white/95"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 block truncate text-[13px] leading-[1.35]",
|
||||
isDark
|
||||
? isActive
|
||||
? "text-[#d6eaff]"
|
||||
: "text-[#9ebfe2]"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{agent.description
|
||||
? desensitize(agent.description)
|
||||
: "智能体助手"}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
|
||||
isDark
|
||||
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
|
||||
: "bg-primary",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -138,6 +138,7 @@ export function AgentDetailPanel({
|
||||
disabled,
|
||||
defaultCollapsed,
|
||||
showRecentChats,
|
||||
compactAppearanceActions,
|
||||
}: {
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
@@ -147,6 +148,8 @@ export function AgentDetailPanel({
|
||||
defaultCollapsed?: boolean;
|
||||
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
|
||||
showRecentChats?: boolean;
|
||||
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
|
||||
compactAppearanceActions?: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
||||
@@ -830,6 +833,7 @@ export function AgentDetailPanel({
|
||||
<span className="text-sm font-semibold">智能体信息</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{headerActions}
|
||||
{compactAppearanceActions}
|
||||
{collapseButton}
|
||||
</div>
|
||||
</div>
|
||||
@@ -841,17 +845,20 @@ export function AgentDetailPanel({
|
||||
<>
|
||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
||||
{collapsed ? (
|
||||
<div className="hidden shrink-0 flex-col items-center border-l pt-3 lg:flex">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-label="展开智能体信息"
|
||||
>
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
||||
<div className="flex items-center gap-1">
|
||||
{compactAppearanceActions}
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-label="展开智能体信息"
|
||||
>
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<aside className="bg-card/40 hidden h-full w-[360px] shrink-0 flex-col border-l lg:flex">
|
||||
|
||||
@@ -211,34 +211,72 @@ export function AgentModuleSidebar({
|
||||
<Link
|
||||
key={`${keyPrefix}:${agent.id}`}
|
||||
href={href}
|
||||
onClick={() => {
|
||||
try {
|
||||
sessionStorage.setItem(`agent-module:${moduleKey}:last-agent`, agent.id);
|
||||
} catch {
|
||||
// Ignore storage failures; route navigation is the source of truth.
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors",
|
||||
"group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 transition-all duration-150",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-transparent hover:border-border hover:bg-accent/50",
|
||||
? isDark
|
||||
? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]"
|
||||
: "border-primary/50 bg-primary/10 shadow-sm"
|
||||
: isDark
|
||||
? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60"
|
||||
: "border-transparent hover:border-border hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
|
||||
className={cn(
|
||||
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
|
||||
isDark &&
|
||||
isActive &&
|
||||
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
|
||||
)}
|
||||
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
|
||||
>
|
||||
<Bot className="size-[18px]" />
|
||||
<Bot className="size-[19px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[13px] font-semibold",
|
||||
isActive ? "text-primary" : "text-foreground",
|
||||
"block truncate text-[16px] leading-5 font-bold",
|
||||
isActive
|
||||
? isDark
|
||||
? "text-white"
|
||||
: "text-primary"
|
||||
: isDark
|
||||
? "text-white/95"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 block truncate text-[13px] leading-[1.35]",
|
||||
isDark
|
||||
? isActive
|
||||
? "text-[#d6eaff]"
|
||||
: "text-[#9ebfe2]"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{agent.description ? desensitize(agent.description) : "智能体助手"}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
|
||||
isDark
|
||||
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
|
||||
: "bg-primary",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
|
||||
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -86,6 +87,7 @@ export function AgentSelector({
|
||||
};
|
||||
|
||||
const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
|
||||
const iconTooltipLabel = selectedAgent ? triggerLabel : "未选择";
|
||||
const iconMode = triggerVariant === "icon";
|
||||
|
||||
return (
|
||||
@@ -93,38 +95,40 @@ export function AgentSelector({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{iconMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
||||
className={cn(
|
||||
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
||||
selectedAgent && "text-amber-600 hover:text-amber-700",
|
||||
selectedAgent && iconActiveClassName,
|
||||
iconButtonClassName,
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-4" />
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip content={iconTooltipLabel}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
|
||||
className={cn(
|
||||
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
|
||||
selectedAgent && "text-amber-600 hover:text-amber-700",
|
||||
selectedAgent && iconActiveClassName,
|
||||
iconButtonClassName,
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-4" />
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
|
||||
import { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
|
||||
/**
|
||||
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
|
||||
@@ -39,6 +40,43 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
|
||||
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const buttons = (appearance?.compact_shortcut_buttons ?? [])
|
||||
.filter((button) => button.enabled && button.label.trim() && button.url.trim())
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
if (buttons.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{buttons.map((button) => (
|
||||
<Button
|
||||
key={button.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 max-w-[168px] shrink-0 gap-1.5 rounded-sm px-2 text-xs font-medium",
|
||||
triggerToneClass(tone),
|
||||
)}
|
||||
title={button.label}
|
||||
onClick={() => {
|
||||
const url = buildLightAppUrl({
|
||||
url: button.url,
|
||||
routeParams: button.route_params.map((param) => ({ ...param })),
|
||||
});
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-4 shrink-0" />
|
||||
<span className="truncate">{button.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -136,9 +174,11 @@ const LAYOUT_OPTIONS = [
|
||||
export function LayoutModeSwitcher({
|
||||
tone,
|
||||
fallback = "compact",
|
||||
navigateTo = "/page/workspace/chats/new",
|
||||
}: {
|
||||
tone: AppearanceControlTone;
|
||||
fallback?: "full" | "compact";
|
||||
navigateTo?: string | null;
|
||||
}) {
|
||||
const [mode, setLayoutMode] = useLayoutMode();
|
||||
const navigate = useNavigate();
|
||||
@@ -170,7 +210,9 @@ export function LayoutModeSwitcher({
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
setLayoutMode(option.id);
|
||||
navigate("/page/workspace/chats/new");
|
||||
if (navigateTo) {
|
||||
navigate(navigateTo);
|
||||
}
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
ArtifactHeader,
|
||||
ArtifactTitle,
|
||||
} from "@/components/ai-elements/artifact";
|
||||
import { Select, SelectItem } from "@/components/ui/select";
|
||||
import { Select, SelectItem } from "@/components/ui/select";
|
||||
import {
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
@@ -41,7 +41,8 @@ import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
|
||||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
} from "@/core/artifacts/download";
|
||||
@@ -51,9 +52,8 @@ import {
|
||||
} from "@/core/artifacts/hooks";
|
||||
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import {
|
||||
checkCodeFile,
|
||||
getFileName,
|
||||
@@ -81,7 +81,8 @@ import { useThread } from "../messages/context";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { useArtifacts } from "./context";
|
||||
import { SpreadsheetPreview } from "./spreadsheet-preview";
|
||||
import { SpreadsheetPreview } from "./spreadsheet-preview";
|
||||
import { SkillInstallDialog } from "./skill-install-dialog";
|
||||
|
||||
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
||||
|
||||
@@ -238,7 +239,7 @@ export function ArtifactFileDetail({
|
||||
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview" | "edit">("code");
|
||||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
|
||||
@@ -439,29 +440,7 @@ export function ArtifactFileDetail({
|
||||
}, 1600);
|
||||
}, [scrollArtifactToBottom]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
try {
|
||||
const result = await installSkill({
|
||||
thread_id: threadId,
|
||||
path: filepath,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message ?? "Failed to install skill");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to install skill:", error);
|
||||
toast.error("Failed to install skill");
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
|
||||
const handleSaveArtifact = useCallback(async () => {
|
||||
const handleSaveArtifact = useCallback(async () => {
|
||||
if (!canEditArtifact || updateArtifact.isPending) {
|
||||
return;
|
||||
}
|
||||
@@ -704,18 +683,17 @@ export function ArtifactFileDetail({
|
||||
)}
|
||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||
label={t.common.install}
|
||||
tooltip={t.common.install}
|
||||
disabled={
|
||||
isInstalling ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
onClick={handleInstallSkill}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={PackageIcon}
|
||||
label={t.common.install}
|
||||
tooltip={t.common.install}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
onClick={() => setInstallDialogOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isCodeFile && (
|
||||
<ArtifactAction
|
||||
icon={CopyIcon}
|
||||
@@ -723,7 +701,7 @@ export function ArtifactFileDetail({
|
||||
disabled={!content}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayContent ?? "");
|
||||
await copyTextToClipboard(displayContent ?? "");
|
||||
toast.success(t.clipboard.copiedToClipboard);
|
||||
} catch (error) {
|
||||
toast.error("Failed to copy to clipboard");
|
||||
@@ -733,7 +711,7 @@ export function ArtifactFileDetail({
|
||||
tooltip={t.clipboard.copyToClipboard}
|
||||
/>
|
||||
)}
|
||||
{!useWriteFileStreaming && language === "markdown" && (
|
||||
{language === "markdown" && (
|
||||
<ArtifactAction
|
||||
icon={isExportingWord ? LoaderIcon : FileType2Icon}
|
||||
label="另存为 Word"
|
||||
@@ -800,7 +778,7 @@ export function ArtifactFileDetail({
|
||||
</ArtifactActions>
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent
|
||||
<ArtifactContent
|
||||
ref={artifactContentRef}
|
||||
className="relative p-0"
|
||||
onWheelCapture={handlePauseAutoScroll}
|
||||
@@ -888,10 +866,16 @@ export function ArtifactFileDetail({
|
||||
{t.common.loading}
|
||||
</div>
|
||||
))}
|
||||
</ArtifactContent>
|
||||
</Artifact>
|
||||
);
|
||||
}
|
||||
</ArtifactContent>
|
||||
<SkillInstallDialog
|
||||
open={installDialogOpen}
|
||||
onOpenChange={setInstallDialogOpen}
|
||||
threadId={threadId}
|
||||
filepath={filepath}
|
||||
/>
|
||||
</Artifact>
|
||||
);
|
||||
}
|
||||
|
||||
function SandboxPreviewImage({
|
||||
src,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -10,18 +10,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { downloadArtifactFile } from "@/core/artifacts/download";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import {
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
getFileName,
|
||||
} from "@/core/utils/files";
|
||||
import { downloadArtifactFile } from "@/core/artifacts/download";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
getFileName,
|
||||
} from "@/core/utils/files";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useThread } from "../messages/context";
|
||||
import { useArtifacts } from "./context";
|
||||
import { useThread } from "../messages/context";
|
||||
import { useArtifacts } from "./context";
|
||||
import { SkillInstallDialog } from "./skill-install-dialog";
|
||||
|
||||
export function ArtifactFileList({
|
||||
className,
|
||||
@@ -32,11 +32,11 @@ export function ArtifactFileList({
|
||||
files: string[];
|
||||
threadId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||
const [installingFile, setInstallingFile] = useState<string | null>(null);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||
const { t } = useI18n();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||
const [installFile, setInstallFile] = useState<string | null>(null);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(filepath: string) => {
|
||||
@@ -46,33 +46,14 @@ export function ArtifactFileList({
|
||||
[selectArtifact, setOpen],
|
||||
);
|
||||
|
||||
const handleInstallSkill = useCallback(
|
||||
async (e: React.MouseEvent, filepath: string) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (installingFile) return;
|
||||
|
||||
setInstallingFile(filepath);
|
||||
try {
|
||||
const result = await installSkill({
|
||||
thread_id: threadId,
|
||||
path: filepath,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message || "Failed to install skill");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to install skill:", error);
|
||||
toast.error("Failed to install skill");
|
||||
} finally {
|
||||
setInstallingFile(null);
|
||||
}
|
||||
},
|
||||
[threadId, installingFile],
|
||||
);
|
||||
const handleInstallSkill = useCallback(
|
||||
(e: React.MouseEvent, filepath: string) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setInstallFile(filepath);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (e: React.MouseEvent, filepath: string) => {
|
||||
@@ -113,20 +94,15 @@ export function ArtifactFileList({
|
||||
{getFileExtensionDisplayName(file)} file
|
||||
</CardDescription>
|
||||
<CardAction className="row-span-1 self-center">
|
||||
{file.endsWith(".skill") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={installingFile === file}
|
||||
onClick={(e) => handleInstallSkill(e, file)}
|
||||
>
|
||||
{installingFile === file ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PackageIcon className="size-4" />
|
||||
)}
|
||||
{t.common.install}
|
||||
</Button>
|
||||
)}
|
||||
{file.endsWith(".skill") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => handleInstallSkill(e, file)}
|
||||
>
|
||||
<PackageIcon className="size-4" />
|
||||
{t.common.install}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={downloadingFile === file}
|
||||
@@ -142,7 +118,15 @@ export function ArtifactFileList({
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
))}
|
||||
<SkillInstallDialog
|
||||
open={!!installFile}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setInstallFile(null);
|
||||
}}
|
||||
threadId={threadId}
|
||||
filepath={installFile}
|
||||
/>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircleIcon, Loader2Icon, PackageIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
installSkill,
|
||||
previewSkillInstall,
|
||||
type PreviewSkillInstallResponse,
|
||||
} from "@/core/skills/api";
|
||||
|
||||
export function SkillInstallDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
threadId,
|
||||
filepath,
|
||||
onInstalled,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
threadId: string;
|
||||
filepath: string | null;
|
||||
onInstalled?: (skillName: string) => void;
|
||||
}) {
|
||||
const [preview, setPreview] = useState<PreviewSkillInstallResponse | null>(null);
|
||||
const [installName, setInstallName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !filepath) {
|
||||
setPreview(null);
|
||||
setInstallName("");
|
||||
setLoading(false);
|
||||
setInstalling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setPreview(null);
|
||||
setInstallName("");
|
||||
|
||||
void previewSkillInstall({ thread_id: threadId, path: filepath })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setPreview(result);
|
||||
setInstallName(result.skill_name);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
toast.error(error instanceof Error ? error.message : "Failed to preview skill install");
|
||||
onOpenChange(false);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filepath, onOpenChange, open, threadId]);
|
||||
|
||||
const trimmedName = installName.trim();
|
||||
const unchangedConflict = useMemo(() => {
|
||||
if (!preview) return false;
|
||||
return trimmedName === preview.skill_name && !preview.can_install_as_is;
|
||||
}, [preview, trimmedName]);
|
||||
|
||||
async function handleInstall() {
|
||||
if (!filepath || !trimmedName || installing) return;
|
||||
setInstalling(true);
|
||||
try {
|
||||
const result = await installSkill({
|
||||
thread_id: threadId,
|
||||
path: filepath,
|
||||
skill_name: trimmedName,
|
||||
});
|
||||
if (!result.success) {
|
||||
toast.error(result.message || "Failed to install skill");
|
||||
return;
|
||||
}
|
||||
toast.success(result.message);
|
||||
onInstalled?.(result.skill_name);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to install skill");
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
const helperText = preview?.owned_by_other
|
||||
? `该技能名已被 ${preview.owner_username || "其他用户"} 安装。你可以换个名字继续安装。`
|
||||
: preview?.owned_by_me
|
||||
? "你已经安装过这个技能了;如果想保留一份新副本,可以换个名字安装。"
|
||||
: preview?.builtin_conflict
|
||||
? "这个名字已被公共技能占用,请换个名字安装。"
|
||||
: "可以直接安装,也可以先改一个更适合自己的名字。";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>安装技能</DialogTitle>
|
||||
<DialogDescription>安装前会校验技能归属;如果名字已被占用,可以改名后继续安装。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在检查技能安装权限…
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border bg-muted/35 p-3 text-sm">
|
||||
<div className="font-medium">{preview.skill_name}</div>
|
||||
{preview.description ? (
|
||||
<div className="text-muted-foreground mt-1 line-clamp-3">{preview.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">安装名称</div>
|
||||
<Input
|
||||
value={installName}
|
||||
onChange={(e) => setInstallName(e.currentTarget.value)}
|
||||
placeholder="请输入技能名称"
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">{helperText}</div>
|
||||
</div>
|
||||
|
||||
{!preview.can_install_as_is && (
|
||||
<div className="flex gap-2 rounded-xl border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
<AlertCircleIcon className="mt-0.5 size-4 shrink-0" />
|
||||
<div>{preview.message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.warnings.length > 0 && (
|
||||
<div className="text-muted-foreground rounded-xl border px-3 py-2 text-xs">
|
||||
{preview.warnings.join(" ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={installing}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleInstall()}
|
||||
disabled={loading || installing || !trimmedName || unchangedConflict}
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PackageIcon className="size-4" />
|
||||
)}
|
||||
安装
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useCallback, useState, type ComponentProps } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useCallback, useState, type ComponentProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
@@ -12,14 +14,18 @@ export function CopyButton({
|
||||
}: ComponentProps<typeof Button> & {
|
||||
clipboardData: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(() => {
|
||||
void navigator.clipboard.writeText(clipboardData);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [clipboardData]);
|
||||
return (
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await copyTextToClipboard(clipboardData);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||
}
|
||||
}, [clipboardData, t.clipboard.failedToCopyToClipboard]);
|
||||
return (
|
||||
<Tooltip content={t.clipboard.copyToClipboard}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
|
||||
@@ -349,33 +349,64 @@ export function GeneralQaSidebar() {
|
||||
type="button"
|
||||
onClick={() => handleSelectAgent(agent.id)}
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 text-left transition-colors",
|
||||
"group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-transparent hover:border-border hover:bg-accent/50",
|
||||
? isDark
|
||||
? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]"
|
||||
: "border-primary/50 bg-primary/10 shadow-sm"
|
||||
: isDark
|
||||
? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60"
|
||||
: "border-transparent hover:border-border hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
|
||||
className={cn(
|
||||
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
|
||||
isDark &&
|
||||
isActive &&
|
||||
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
|
||||
)}
|
||||
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
|
||||
>
|
||||
<Bot className="size-[18px]" />
|
||||
<Bot className="size-[19px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[13px] font-semibold",
|
||||
isActive ? "text-primary" : "text-foreground",
|
||||
"block truncate text-[16px] leading-5 font-bold",
|
||||
isActive
|
||||
? isDark
|
||||
? "text-white"
|
||||
: "text-primary"
|
||||
: isDark
|
||||
? "text-white/95"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 block truncate text-[13px] leading-[1.35]",
|
||||
isDark
|
||||
? isActive
|
||||
? "text-[#d6eaff]"
|
||||
: "text-[#9ebfe2]"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{agent.description ? desensitize(agent.description) : "智能体助手"}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
|
||||
isDark
|
||||
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
|
||||
: "bg-primary",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { DropletIcon, LayoutGridIcon, LayoutIcon, ListIcon, MoonIcon, PanelLeftIcon, SunIcon } from "lucide-react";
|
||||
import { useMemo, useState, type ComponentType, type SVGProps } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
DropletIcon,
|
||||
ExternalLink,
|
||||
LayoutGridIcon,
|
||||
LayoutIcon,
|
||||
ListIcon,
|
||||
MoonIcon,
|
||||
PanelLeftIcon,
|
||||
Plus,
|
||||
SunIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
@@ -25,14 +39,24 @@ import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-sessio
|
||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useCitationDisplaySettings,
|
||||
useLayoutMode,
|
||||
useUpdateAppearanceSettings,
|
||||
useUpdateCitationDisplaySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from "@/core/system-settings/hooks";
|
||||
import type {
|
||||
CompactShortcutButton,
|
||||
CompactShortcutRouteParam,
|
||||
} from "@/core/system-settings/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
APP_THEME_COLORS,
|
||||
LOGIN_SOURCE_OPTIONS,
|
||||
parseThemeMap,
|
||||
stringifyThemeMap,
|
||||
} from "@/strategy-components/lib/light-app";
|
||||
|
||||
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
||||
@@ -41,10 +65,101 @@ import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
const languageOptions: { value: Locale; label: string }[] = [
|
||||
{ value: "en-US", label: enUS.locale.localName },
|
||||
{ value: "zh-CN", label: zhCN.locale.localName },
|
||||
];
|
||||
const languageOptions: { value: Locale; label: string }[] = [
|
||||
{ value: "en-US", label: enUS.locale.localName },
|
||||
{ value: "zh-CN", label: zhCN.locale.localName },
|
||||
];
|
||||
|
||||
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;blue=blue;dark-blue=dark-blue";
|
||||
|
||||
function makeShortcutId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function newShortcutParam(): CompactShortcutRouteParam {
|
||||
return {
|
||||
key: "access_token",
|
||||
paramType: "login",
|
||||
source: "access_token",
|
||||
customValue: "",
|
||||
themeValue: DEFAULT_SHORTCUT_THEME_MAP,
|
||||
};
|
||||
}
|
||||
|
||||
function newShortcutButton(): CompactShortcutButton {
|
||||
return {
|
||||
id: makeShortcutId(),
|
||||
label: "快捷入口",
|
||||
url: "",
|
||||
route_params: [newShortcutParam()],
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeShortcutDraft(
|
||||
buttons: CompactShortcutButton[] | undefined,
|
||||
): CompactShortcutButton[] {
|
||||
const source = buttons?.length ? buttons : [newShortcutButton()];
|
||||
return [...source]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((button, index) => ({
|
||||
...button,
|
||||
id: button.id || makeShortcutId(),
|
||||
label: button.label || "快捷入口",
|
||||
url: button.url || "",
|
||||
route_params: (button.route_params?.length
|
||||
? button.route_params
|
||||
: [newShortcutParam()]
|
||||
).map((param) => ({
|
||||
key: param.key || "",
|
||||
paramType: param.paramType || "login",
|
||||
source: param.source || "access_token",
|
||||
customValue: param.customValue ?? "",
|
||||
themeValue: param.themeValue || DEFAULT_SHORTCUT_THEME_MAP,
|
||||
})),
|
||||
enabled: button.enabled ?? true,
|
||||
sort_order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
function cleanShortcutParams(
|
||||
params: CompactShortcutRouteParam[],
|
||||
): CompactShortcutRouteParam[] {
|
||||
return params
|
||||
.filter((param) => param.key.trim())
|
||||
.map((param) => {
|
||||
const base: CompactShortcutRouteParam = {
|
||||
key: param.key.trim(),
|
||||
paramType: param.paramType,
|
||||
};
|
||||
if (param.paramType === "theme") {
|
||||
base.themeValue = stringifyThemeMap(parseThemeMap(param.themeValue));
|
||||
return base;
|
||||
}
|
||||
base.source = param.source || "access_token";
|
||||
if (base.source === "other") {
|
||||
base.customValue = (param.customValue ?? "").trim();
|
||||
}
|
||||
return base;
|
||||
});
|
||||
}
|
||||
|
||||
function cleanShortcutButtons(
|
||||
buttons: CompactShortcutButton[],
|
||||
): CompactShortcutButton[] {
|
||||
return buttons.map((button, index) => ({
|
||||
id: button.id || makeShortcutId(),
|
||||
label: button.label.trim() || "快捷入口",
|
||||
url: button.url.trim(),
|
||||
route_params: cleanShortcutParams(button.route_params),
|
||||
enabled: button.enabled,
|
||||
sort_order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const { t, locale, changeLocale } = useI18n();
|
||||
@@ -52,10 +167,11 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const currentTheme = theme;
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const updateAppearance = useUpdateAppearanceSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const updateAppearance = useUpdateAppearanceSettings();
|
||||
const [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||
@@ -63,8 +179,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
|
||||
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
|
||||
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
setShortcutDraft(
|
||||
normalizeShortcutDraft(appearance?.compact_shortcut_buttons),
|
||||
);
|
||||
}, [appearance?.compact_shortcut_buttons]);
|
||||
|
||||
// 独立登录会话(本标签页):开关切换鉴权存储 localStorage ↔ sessionStorage(见 scoped-storage)。
|
||||
const [isolatedSession, setIsolatedSessionState] = useState(() => isIsolatedSession());
|
||||
@@ -115,7 +237,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefaultView = async (
|
||||
const handleSetDefaultView = async (
|
||||
kind: "skill" | "agent",
|
||||
value: "card" | "list",
|
||||
) => {
|
||||
@@ -134,7 +256,103 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const patchShortcut = (
|
||||
id: string,
|
||||
patch: Partial<CompactShortcutButton>,
|
||||
) => {
|
||||
setShortcutDraft((items) =>
|
||||
items.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
const patchShortcutParam = (
|
||||
id: string,
|
||||
index: number,
|
||||
patch: Partial<CompactShortcutRouteParam>,
|
||||
) => {
|
||||
setShortcutDraft((items) =>
|
||||
items.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
route_params: item.route_params.map((param, paramIndex) =>
|
||||
paramIndex === index ? { ...param, ...patch } : param,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const moveShortcut = (id: string, direction: -1 | 1) => {
|
||||
setShortcutDraft((items) => {
|
||||
const index = items.findIndex((item) => item.id === id);
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= items.length) return items;
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
return next.map((item, order) => ({ ...item, sort_order: order }));
|
||||
});
|
||||
};
|
||||
|
||||
const addShortcutParam = (id: string) => {
|
||||
setShortcutDraft((items) =>
|
||||
items.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
route_params: [...item.route_params, newShortcutParam()],
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeShortcutParam = (id: string, index: number) => {
|
||||
setShortcutDraft((items) =>
|
||||
items.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
route_params: item.route_params.filter((_, paramIndex) => paramIndex !== index),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveShortcuts = async () => {
|
||||
for (const item of shortcutDraft) {
|
||||
if (item.enabled && !item.url.trim()) {
|
||||
toast.error(`请填写「${item.label.trim() || "快捷入口"}」的跳转链接,或先关闭该按钮`);
|
||||
return;
|
||||
}
|
||||
for (const param of item.route_params) {
|
||||
if (!param.key.trim()) {
|
||||
toast.error("路由参数键名不可为空");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
param.paramType === "login" &&
|
||||
param.source === "other" &&
|
||||
!param.customValue?.trim()
|
||||
) {
|
||||
toast.error("登录参数自定义值不可为空");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await updateAppearance.mutateAsync({
|
||||
compact_shortcut_buttons: cleanShortcutButtons(shortcutDraft),
|
||||
});
|
||||
toast.success("顶部快捷按钮已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
@@ -224,11 +442,11 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认界面模式(管理员)"
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认界面模式(管理员)"
|
||||
description="给所有还没有手动切换过界面模式的用户的默认;已经自己切换过的用户保留其个人选择不受影响。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -257,13 +475,47 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
{globalLayoutMode === "full" ? "全量模式" : "简洁模式"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="顶部快捷按钮(管理员)"
|
||||
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
|
||||
>
|
||||
<CompactShortcutSettings
|
||||
buttons={shortcutDraft}
|
||||
saving={updateAppearance.isPending}
|
||||
onAdd={() =>
|
||||
setShortcutDraft((items) => [
|
||||
...items,
|
||||
{ ...newShortcutButton(), sort_order: items.length },
|
||||
])
|
||||
}
|
||||
onPatch={patchShortcut}
|
||||
onMove={moveShortcut}
|
||||
onRemove={(id) =>
|
||||
setShortcutDraft((items) =>
|
||||
items
|
||||
.filter((item) => item.id !== id)
|
||||
.map((item, index) => ({ ...item, sort_order: index })),
|
||||
)
|
||||
}
|
||||
onAddParam={addShortcutParam}
|
||||
onPatchParam={patchShortcutParam}
|
||||
onRemoveParam={removeShortcutParam}
|
||||
onSave={handleSaveShortcuts}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认主题(管理员)"
|
||||
description="给所有还没有手动切换过主题的用户的默认主题;已经自己切换过的用户保留其个人设置不受影响。"
|
||||
@@ -515,12 +767,304 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreviewCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
);
|
||||
}
|
||||
|
||||
function CompactShortcutSettings({
|
||||
buttons,
|
||||
saving,
|
||||
onAdd,
|
||||
onPatch,
|
||||
onMove,
|
||||
onRemove,
|
||||
onAddParam,
|
||||
onPatchParam,
|
||||
onRemoveParam,
|
||||
onSave,
|
||||
}: {
|
||||
buttons: CompactShortcutButton[];
|
||||
saving: boolean;
|
||||
onAdd: () => void;
|
||||
onPatch: (id: string, patch: Partial<CompactShortcutButton>) => void;
|
||||
onMove: (id: string, direction: -1 | 1) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onAddParam: (id: string) => void;
|
||||
onPatchParam: (
|
||||
id: string,
|
||||
index: number,
|
||||
patch: Partial<CompactShortcutRouteParam>,
|
||||
) => void;
|
||||
onRemoveParam: (id: string, index: number) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
添加按钮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
>
|
||||
保存按钮配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{buttons.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm">
|
||||
暂无按钮,点击“添加按钮”创建一个。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{buttons.map((button, index) => (
|
||||
<div key={button.id} className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-start">
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<Switch
|
||||
checked={button.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onPatch(button.id, { enabled: checked })
|
||||
}
|
||||
aria-label="启用快捷按钮"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{button.enabled ? "启用" : "停用"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium">按钮名称</div>
|
||||
<Input
|
||||
value={button.label}
|
||||
onChange={(event) =>
|
||||
onPatch(button.id, { label: event.target.value })
|
||||
}
|
||||
placeholder="如:业务系统"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium">跳转链接</div>
|
||||
<Input
|
||||
value={button.url}
|
||||
onChange={(event) =>
|
||||
onPatch(button.id, { url: event.target.value })
|
||||
}
|
||||
placeholder="https://example.com/page"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onMove(button.id, -1)}
|
||||
disabled={index === 0}
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onMove(button.id, 1)}
|
||||
disabled={index === buttons.length - 1}
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDown className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onRemove(button.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<ExternalLink className="size-4" />
|
||||
路由参数
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAddParam(button.id)}
|
||||
>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
添加参数
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{button.route_params.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
|
||||
不追加额外参数。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{button.route_params.map((param, paramIndex) => {
|
||||
const themeMap =
|
||||
param.paramType === "theme"
|
||||
? parseThemeMap(param.themeValue)
|
||||
: {};
|
||||
return (
|
||||
<div
|
||||
key={`${button.id}-${paramIndex}`}
|
||||
className="space-y-2 rounded-md border bg-background p-2"
|
||||
>
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="参数名 key"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Select
|
||||
value={param.paramType}
|
||||
onValueChange={(value) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
paramType: value as "login" | "theme",
|
||||
source:
|
||||
value === "login"
|
||||
? param.source || "access_token"
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="login">登录参数</SelectItem>
|
||||
<SelectItem value="theme">主题参数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{param.paramType === "login" ? (
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Select
|
||||
value={param.source || "access_token"}
|
||||
onValueChange={(value) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
source: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-0 flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOGIN_SOURCE_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{param.source === "other" && (
|
||||
<Input
|
||||
value={param.customValue ?? ""}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
customValue: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="自定义值"
|
||||
className="w-32 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center text-xs">
|
||||
按当前主题取值
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() =>
|
||||
onRemoveParam(button.id, paramIndex)
|
||||
}
|
||||
title="删除参数"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{param.paramType === "theme" && (
|
||||
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
|
||||
{APP_THEME_COLORS.map((color) => (
|
||||
<label
|
||||
key={color.key}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: color.dot }}
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{color.label}
|
||||
</span>
|
||||
<Input
|
||||
value={themeMap[color.key] ?? color.key}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
themeValue: stringifyThemeMap({
|
||||
...themeMap,
|
||||
[color.key]: event.target.value,
|
||||
}),
|
||||
})
|
||||
}
|
||||
placeholder={color.key}
|
||||
className="h-8 min-w-0 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreviewCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
active,
|
||||
mode,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -30,11 +30,14 @@ export function WorkspaceContainer({
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceHeader({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"header">) {
|
||||
export function WorkspaceHeader({
|
||||
className,
|
||||
children,
|
||||
rightActions,
|
||||
...props
|
||||
}: React.ComponentProps<"header"> & {
|
||||
rightActions?: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const segments = useMemo(() => {
|
||||
@@ -51,8 +54,8 @@ export function WorkspaceHeader({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<Breadcrumb>
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{segments?.[0] && (
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
@@ -88,11 +91,14 @@ export function WorkspaceHeader({
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{rightActions ? (
|
||||
<div className="flex items-center gap-2 px-4">{rightActions}</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceBody({
|
||||
className,
|
||||
|
||||
@@ -1,268 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BotIcon,
|
||||
BrainIcon,
|
||||
BriefcaseIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
TrophyIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import {
|
||||
loadLogSystemSettings,
|
||||
type LogSystemSettings,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
import { NotificationDialog } from "./notification-dialog";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
|
||||
function NavMenuButtonContent({
|
||||
isSidebarOpen,
|
||||
t,
|
||||
}: {
|
||||
isSidebarOpen: boolean;
|
||||
t: ReturnType<typeof useI18n>["t"];
|
||||
}) {
|
||||
return isSidebarOpen ? (
|
||||
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
|
||||
<SettingsIcon className="size-4" />
|
||||
<span>{t.workspace.settingsAndMore}</span>
|
||||
<ChevronsUpDown className="text-sidebar-foreground/55 ml-auto size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<SettingsIcon className="text-sidebar-foreground/80 size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceNavMenu() {
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
"appearance" | "memory" | "tools" | "about"
|
||||
>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [logSystem, setLogSystem] = useState<LogSystemSettings | null>(null);
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
let cancelled = false;
|
||||
loadLogSystemSettings()
|
||||
.then((settings) => {
|
||||
if (!cancelled) setLogSystem(settings);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLogSystem(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAdmin]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<SidebarMenu className="w-full">
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||
<BellIcon />
|
||||
{t.notifications.title}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("appearance");
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings2Icon />
|
||||
{t.common.settings}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/llm-metrics")
|
||||
}
|
||||
>
|
||||
<GaugeIcon />
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/checkpoint-migration")
|
||||
}
|
||||
>
|
||||
<DatabaseZapIcon />
|
||||
Checkpoint 迁移
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/scheduled-tasks")
|
||||
}
|
||||
>
|
||||
<CalendarClockIcon />
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/agents")}
|
||||
>
|
||||
<BotIcon />
|
||||
智能体管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skills")}
|
||||
>
|
||||
<SparklesIcon />
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/positions")}
|
||||
>
|
||||
<BriefcaseIcon />
|
||||
岗位管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate(
|
||||
"/page/workspace/settings/recommended-questions",
|
||||
)
|
||||
}
|
||||
>
|
||||
<MessageCircleQuestionIcon />
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
window.open(
|
||||
logSystem.url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ScrollTextIcon />
|
||||
{logSystem.label || "日志系统"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<SidebarMenuButton size="lg" className="pointer-events-none">
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BotIcon,
|
||||
BrainIcon,
|
||||
BriefcaseIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
PlugIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
TrophyIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import {
|
||||
loadLogSystemSettings,
|
||||
type LogSystemSettings,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
import { NotificationDialog } from "./notification-dialog";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
|
||||
function NavMenuButtonContent({
|
||||
isSidebarOpen,
|
||||
t,
|
||||
}: {
|
||||
isSidebarOpen: boolean;
|
||||
t: ReturnType<typeof useI18n>["t"];
|
||||
}) {
|
||||
return isSidebarOpen ? (
|
||||
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
|
||||
<SettingsIcon className="size-4" />
|
||||
<span>{t.workspace.settingsAndMore}</span>
|
||||
<ChevronsUpDown className="text-sidebar-foreground/55 ml-auto size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<SettingsIcon className="text-sidebar-foreground/80 size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceNavMenu() {
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
"appearance" | "memory" | "tools" | "about"
|
||||
>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [logSystem, setLogSystem] = useState<LogSystemSettings | null>(null);
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
let cancelled = false;
|
||||
loadLogSystemSettings()
|
||||
.then((settings) => {
|
||||
if (!cancelled) setLogSystem(settings);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLogSystem(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAdmin]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<SidebarMenu className="w-full">
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||
<BellIcon />
|
||||
{t.notifications.title}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("appearance");
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings2Icon />
|
||||
{t.common.settings}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/llm-metrics")
|
||||
}
|
||||
>
|
||||
<GaugeIcon />
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/checkpoint-migration")
|
||||
}
|
||||
>
|
||||
<DatabaseZapIcon />
|
||||
Checkpoint 迁移
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/scheduled-tasks")
|
||||
}
|
||||
>
|
||||
<CalendarClockIcon />
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/agents")}
|
||||
>
|
||||
<BotIcon />
|
||||
智能体管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skills")}
|
||||
>
|
||||
<SparklesIcon />
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/plugins")}
|
||||
>
|
||||
<PlugIcon />
|
||||
插件管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/positions")}
|
||||
>
|
||||
<BriefcaseIcon />
|
||||
岗位管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate(
|
||||
"/page/workspace/settings/recommended-questions",
|
||||
)
|
||||
}
|
||||
>
|
||||
<MessageCircleQuestionIcon />
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
window.open(
|
||||
logSystem.url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ScrollTextIcon />
|
||||
{logSystem.label || "日志系统"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<SidebarMenuButton size="lg" className="pointer-events-none">
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -233,6 +233,12 @@ export async function fetchDashboardInputJson(
|
||||
*/
|
||||
export type SqReportStatus = "complete" | "incomplete" | "unknown";
|
||||
|
||||
export interface SqReportCheckResult {
|
||||
status: SqReportStatus;
|
||||
/** 提取后的 3q 研判情报报告;仅 status=complete 时有值。 */
|
||||
report?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport``
|
||||
* 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。
|
||||
@@ -245,20 +251,26 @@ export type SqReportStatus = "complete" | "incomplete" | "unknown";
|
||||
* (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]);
|
||||
* - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。
|
||||
*/
|
||||
export async function fetchSqReportStatus(taskId: string): Promise<SqReportStatus> {
|
||||
if (!taskId) return "unknown";
|
||||
export async function fetchSqReportCheck(taskId: string): Promise<SqReportCheckResult> {
|
||||
if (!taskId) return { status: "unknown" };
|
||||
const cfg = await fetchTaskDeeplinkConfig();
|
||||
// test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。
|
||||
if (cfg.test) {
|
||||
return extractSqReport(cfg.mock.sqReport, taskId) ? "complete" : "incomplete";
|
||||
const report = extractSqReport(cfg.mock.sqReport, taskId);
|
||||
return report ? { status: "complete", report } : { status: "incomplete" };
|
||||
}
|
||||
// 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。
|
||||
const url = sqReportUrl();
|
||||
if (!url) return "unknown";
|
||||
if (!url) return { status: "unknown" };
|
||||
try {
|
||||
const body = await requestCrudService.get(url, { taskId });
|
||||
return extractSqReport(body, taskId) ? "complete" : "incomplete";
|
||||
const report = extractSqReport(body, taskId);
|
||||
return report ? { status: "complete", report } : { status: "incomplete" };
|
||||
} catch {
|
||||
return "unknown";
|
||||
return { status: "unknown" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSqReportStatus(taskId: string): Promise<SqReportStatus> {
|
||||
return (await fetchSqReportCheck(taskId)).status;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
|
||||
|
||||
export const EXTERNAL_ACCESS_TOKEN_STORAGE_KEY = "access_token";
|
||||
|
||||
export function normalizeExternalAccessToken(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
const token = String(value).trim();
|
||||
if (!token) return "";
|
||||
const lowered = token.toLowerCase();
|
||||
return lowered === "null" || lowered === "undefined" ? "" : token;
|
||||
}
|
||||
|
||||
function readTokenFromStoredValue(raw: string | null): string {
|
||||
const token = normalizeExternalAccessToken(raw);
|
||||
if (!token) return "";
|
||||
if (!token.startsWith("{")) return token;
|
||||
try {
|
||||
const parsed = JSON.parse(token);
|
||||
if (!parsed || typeof parsed !== "object") return token;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
return (
|
||||
normalizeExternalAccessToken(obj.access_token) ||
|
||||
normalizeExternalAccessToken(obj.accessToken) ||
|
||||
normalizeExternalAccessToken(obj.token)
|
||||
);
|
||||
} catch {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredExternalAccessTokenValue(): string {
|
||||
return readTokenFromStoredValue(authStorageGet(EXTERNAL_ACCESS_TOKEN_STORAGE_KEY));
|
||||
}
|
||||
|
||||
export function readExternalAccessTokenValue(userInfo: Record<string, unknown>): string {
|
||||
return normalizeExternalAccessToken(userInfo.access_token) || normalizeExternalAccessToken(userInfo.accessToken);
|
||||
}
|
||||
|
||||
export function readCurrentExternalAccessTokenValue(userInfo: Record<string, unknown>): string {
|
||||
return readStoredExternalAccessTokenValue() || readExternalAccessTokenValue(userInfo);
|
||||
}
|
||||
|
||||
export function writeExternalAccessTokenFields(
|
||||
userInfo: Record<string, unknown>,
|
||||
token: string,
|
||||
): void {
|
||||
userInfo.access_token = token;
|
||||
userInfo.accessToken = token;
|
||||
}
|
||||
|
||||
export function writeStoredExternalAccessToken(token: string): void {
|
||||
const normalized = normalizeExternalAccessToken(token);
|
||||
if (normalized) {
|
||||
authStorageSet(EXTERNAL_ACCESS_TOKEN_STORAGE_KEY, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteExternalAccessTokenFields(userInfo: Record<string, unknown>): void {
|
||||
delete userInfo.access_token;
|
||||
delete userInfo.accessToken;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export function authStorageRemove(key: string): void {
|
||||
* ticket/userId/env/maxkeyOnlineToken/userDetailToken)。用于「开启独立会话」时把当前登录态从
|
||||
* localStorage 快照搬进 sessionStorage,避免切换存储后被登出。
|
||||
*/
|
||||
const PROTECTED_AUTH_EXACT_KEYS = ["deerflow.auth", "userInfo", "userId", "tools_token"];
|
||||
const PROTECTED_AUTH_EXACT_KEYS = ["deerflow.auth", "userInfo", "userId", "tools_token", "access_token"];
|
||||
const PROTECTED_AUTH_PREFIX = "login.";
|
||||
|
||||
function isProtectedAuthKey(key: string): boolean {
|
||||
|
||||
@@ -234,7 +234,7 @@ export interface TaskDeeplinkTarget {
|
||||
}
|
||||
|
||||
/** xdfx(行动分析)开场信息里要追加的行动信息:行动标题 + 行动内容 + 行动id。 */
|
||||
interface ActionOpeningInfo {
|
||||
export interface ActionOpeningInfo {
|
||||
/** 行动id(action_id)。 */
|
||||
actionId?: string;
|
||||
/** 行动标题(行动名称 actionName)。 */
|
||||
@@ -264,8 +264,12 @@ function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: Action
|
||||
* 3qfx/rwfx 这类“按任务id 直接问答”的任务工作区,在 ``TaskContext`` 丢失但
|
||||
* ``login.copTaskDetail`` 仍存在时,可用它重新拼出开场词做前端自恢复。
|
||||
*/
|
||||
export function buildTaskOpeningText(detail: CopTaskDetail, taskId: string): string {
|
||||
return buildOpeningText(detail, taskId);
|
||||
export function buildTaskOpeningText(
|
||||
detail: CopTaskDetail,
|
||||
taskId: string,
|
||||
action?: ActionOpeningInfo,
|
||||
): string {
|
||||
return buildOpeningText(detail, taskId, action);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
|
||||
import {
|
||||
deleteExternalAccessTokenFields,
|
||||
normalizeExternalAccessToken,
|
||||
readStoredExternalAccessTokenValue,
|
||||
readExternalAccessTokenValue,
|
||||
writeExternalAccessTokenFields,
|
||||
writeStoredExternalAccessToken,
|
||||
} from "@/core/auth/external-access-token";
|
||||
|
||||
const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || "";
|
||||
|
||||
@@ -8,6 +16,8 @@ export const EXTERNAL_USER_ID_STORAGE_KEY = "userId";
|
||||
export const EXTERNAL_USER_INFO_STORAGE_KEY = "userInfo";
|
||||
export const GENERAL_QA_ROUTE = "/page/workspace/chats/new";
|
||||
|
||||
const YUSER_INFO_CACHE_KEY = "deerflow.yuserInfoByYUserId";
|
||||
|
||||
type UserDetailResponse = {
|
||||
data?: {
|
||||
id?: string;
|
||||
@@ -15,6 +25,147 @@ type UserDetailResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
function readExternalUserInfo(): Record<string, unknown> {
|
||||
try {
|
||||
const raw = authStorageGet(EXTERNAL_USER_INFO_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredYUserId(info: Record<string, unknown>): string {
|
||||
const value = info.yUserId;
|
||||
return value == null ? "" : String(value).trim();
|
||||
}
|
||||
|
||||
function normalizeExternalUserId(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
const text = String(value).trim();
|
||||
if (!text) return "";
|
||||
const lowered = text.toLowerCase();
|
||||
return lowered === "null" || lowered === "undefined" ? "" : text;
|
||||
}
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (!token) return "";
|
||||
if (token.length <= 10) return `${token.slice(0, 2)}***(${token.length})`;
|
||||
return `${token.slice(0, 6)}...${token.slice(-4)}(${token.length})`;
|
||||
}
|
||||
|
||||
function summarizeUserInfo(info: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
keys: Object.keys(info),
|
||||
yUserId: info.yUserId,
|
||||
userId: info.userId,
|
||||
userName: info.userName,
|
||||
gateDepartName: info.gateDepartName,
|
||||
typeName: info.typeName,
|
||||
access_token: maskToken(normalizeExternalAccessToken(info.access_token)),
|
||||
accessToken: maskToken(normalizeExternalAccessToken(info.accessToken)),
|
||||
};
|
||||
}
|
||||
|
||||
function logYUserLogin(step: string, payload: Record<string, unknown>): void {
|
||||
if (typeof console === "undefined") return;
|
||||
console.info(`[yUserId-login] ${step}`, payload);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isUsableExternalValue(value: unknown): boolean {
|
||||
if (value == null) return false;
|
||||
if (typeof value !== "string") return true;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
return lowered !== "null" && lowered !== "undefined";
|
||||
}
|
||||
|
||||
function readYUserInfoCache(): Record<string, Record<string, unknown>> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(YUSER_INFO_CACHE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!isPlainObject(parsed)) return {};
|
||||
const cache: Record<string, Record<string, unknown>> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (isPlainObject(value)) cache[key] = value;
|
||||
}
|
||||
return cache;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedYUserInfo(yUserId: string): Record<string, unknown> {
|
||||
return readYUserInfoCache()[yUserId] ?? {};
|
||||
}
|
||||
|
||||
function writeCachedYUserInfo(yUserId: string, info: Record<string, unknown>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const cache = readYUserInfoCache();
|
||||
cache[yUserId] = info;
|
||||
window.localStorage.setItem(YUSER_INFO_CACHE_KEY, JSON.stringify(cache));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function buildExternalUserInfo(
|
||||
yUserId: string,
|
||||
result: Record<string, unknown>,
|
||||
access_token: string | null | undefined,
|
||||
previous: Record<string, unknown>,
|
||||
cached: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...cached };
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
if (isUsableExternalValue(value)) {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
next.yUserId = yUserId;
|
||||
const externalUserId =
|
||||
normalizeExternalUserId(result.id) ||
|
||||
normalizeExternalUserId(result.userId) ||
|
||||
normalizeExternalUserId(result.user_id) ||
|
||||
yUserId;
|
||||
next.userId = externalUserId;
|
||||
|
||||
const incomingAccessToken = readExternalAccessTokenValue(result);
|
||||
if (incomingAccessToken) {
|
||||
writeExternalAccessTokenFields(next, incomingAccessToken);
|
||||
return next;
|
||||
}
|
||||
|
||||
const queryAccessToken = normalizeExternalAccessToken(access_token);
|
||||
if (queryAccessToken) {
|
||||
writeExternalAccessTokenFields(next, queryAccessToken);
|
||||
return next;
|
||||
}
|
||||
|
||||
const previousYUserId = readStoredYUserId(previous);
|
||||
const previousAccessToken =
|
||||
previousYUserId === yUserId ? readExternalAccessTokenValue(previous) : "";
|
||||
const cachedAccessToken = readExternalAccessTokenValue(cached);
|
||||
const fallbackAccessToken = previousAccessToken || cachedAccessToken;
|
||||
if (fallbackAccessToken) {
|
||||
writeExternalAccessTokenFields(next, fallbackAccessToken);
|
||||
} else {
|
||||
deleteExternalAccessTokenFields(next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* External login via yUserId + optional token (same flow as web-code Login.tsx):
|
||||
* persist tools_token, resolve user via consumer API, store userId/userInfo.
|
||||
@@ -22,26 +173,61 @@ type UserDetailResponse = {
|
||||
export async function loginByYUserId(
|
||||
yUserId: string,
|
||||
token?: string | null,
|
||||
access_token?: string | null,
|
||||
): Promise<void> {
|
||||
if (token) {
|
||||
authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
|
||||
const requestUrl = `${CONSUMER_URL}/cop/userDetailByYuserId`;
|
||||
logYUserLogin("request", {
|
||||
requestUrl,
|
||||
yUserId,
|
||||
hasToolsToken: Boolean(token),
|
||||
queryAccessToken: maskToken(normalizeExternalAccessToken(access_token)),
|
||||
});
|
||||
|
||||
const res = (await requestCrudService.get(
|
||||
`${CONSUMER_URL}/cop/userDetailByYuserId`,
|
||||
requestUrl,
|
||||
{ yuserId: yUserId },
|
||||
)) as UserDetailResponse;
|
||||
|
||||
// consumer 返回 { accessToken, gateDepartName, typeName, userName }。
|
||||
// 一并存入路由参数 yUserId,使「应用管理」里 source=yUserId 的路由参数能取到(与
|
||||
// source=userName/gateDepartName/typeName 直接取接口值统一走 userInfo[source])。
|
||||
// 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的
|
||||
// access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。
|
||||
// 注意:接口返回的 accessToken 优先;登录 URL 的 access_token 只在接口值为空时兜底。
|
||||
// The consumer API may return accessToken. URL access_token is only used as
|
||||
// a fallback when that API token is empty.
|
||||
const result = res.data ?? {};
|
||||
authStorageSet(
|
||||
EXTERNAL_USER_INFO_STORAGE_KEY,
|
||||
JSON.stringify({ ...result, yUserId }),
|
||||
);
|
||||
logYUserLogin("response", {
|
||||
keys: Object.keys(result),
|
||||
id: result.id,
|
||||
userId: result.userId,
|
||||
user_id: result.user_id,
|
||||
userName: result.userName,
|
||||
gateDepartName: result.gateDepartName,
|
||||
typeName: result.typeName,
|
||||
access_token: maskToken(normalizeExternalAccessToken(result.access_token)),
|
||||
accessToken: maskToken(normalizeExternalAccessToken(result.accessToken)),
|
||||
});
|
||||
const previous = readExternalUserInfo();
|
||||
const cached = readCachedYUserInfo(yUserId);
|
||||
const next = buildExternalUserInfo(yUserId, result, access_token, previous, cached);
|
||||
const externalUserId = normalizeExternalUserId(next.userId);
|
||||
if (externalUserId) {
|
||||
authStorageSet(EXTERNAL_USER_ID_STORAGE_KEY, externalUserId);
|
||||
}
|
||||
const externalAccessToken = readExternalAccessTokenValue(next);
|
||||
if (externalAccessToken) {
|
||||
writeStoredExternalAccessToken(externalAccessToken);
|
||||
}
|
||||
authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(next));
|
||||
writeCachedYUserInfo(yUserId, next);
|
||||
logYUserLogin("stored", {
|
||||
userId: externalUserId,
|
||||
topLevelAccessToken: maskToken(readStoredExternalAccessTokenValue()),
|
||||
userInfo: summarizeUserInfo(next),
|
||||
});
|
||||
}
|
||||
|
||||
export function persistToolsToken(token: string): void {
|
||||
@@ -49,27 +235,21 @@ export function persistToolsToken(token: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 把内网登录路由参数 access_token 合并写入 userInfo.accessToken(覆盖接口返回的同名值)。
|
||||
* 把内网登录路由参数 access_token 合并写入 userInfo.access_token / userInfo.accessToken。
|
||||
* 据此拼参的有:XK/PG 中任务 iframe(XK 用 accessToken、PG 用 access_token,二者均读
|
||||
* external-links.getAccessToken() = userInfo.accessToken);以及「应用管理」里
|
||||
* source=accessToken 的轻应用路由参数(resolveRouteParamValue 取 userInfo.accessToken)。
|
||||
* external-links.getAccessToken());以及「应用管理」里 source=access_token / accessToken
|
||||
* 的轻应用路由参数(resolveRouteParamValue 统一读两种字段)。
|
||||
* 用合并而非覆盖整对象,避免抹掉 loginByYUserId 从 consumer 拿到的用户信息。
|
||||
*/
|
||||
export function persistAccessToken(token: string): void {
|
||||
const trimmed = token?.trim();
|
||||
const trimmed = normalizeExternalAccessToken(token);
|
||||
if (!trimmed) return;
|
||||
let info: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = authStorageGet(EXTERNAL_USER_INFO_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
info = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
info = {};
|
||||
}
|
||||
info.accessToken = trimmed;
|
||||
const info = readExternalUserInfo();
|
||||
writeExternalAccessTokenFields(info, trimmed);
|
||||
writeStoredExternalAccessToken(trimmed);
|
||||
authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info));
|
||||
const yUserId = readStoredYUserId(info);
|
||||
if (yUserId) {
|
||||
writeCachedYUserInfo(yUserId, info);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
let clipboardError: unknown;
|
||||
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch (error) {
|
||||
clipboardError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === "undefined" || !document.body) {
|
||||
throw clipboardError instanceof Error
|
||||
? clipboardError
|
||||
: new Error("Clipboard is not available");
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
textarea.style.width = "1px";
|
||||
textarea.style.height = "1px";
|
||||
textarea.style.opacity = "0";
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
try {
|
||||
const copied = document.execCommand("copy");
|
||||
if (!copied) {
|
||||
throw clipboardError instanceof Error
|
||||
? clipboardError
|
||||
: new Error("Copy command failed");
|
||||
}
|
||||
} finally {
|
||||
textarea.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
normalizeDomainInput,
|
||||
resolveIncomingIframeSelection,
|
||||
resolveConfiguredIframeAgentId,
|
||||
resolveDomainMappedAgentId,
|
||||
} from "./domain-agent.ts";
|
||||
|
||||
test("normalizes full urls and bare hosts to comparable domains", () => {
|
||||
assert.equal(
|
||||
normalizeDomainInput(" https://Foo.Example.com/path?q=1 "),
|
||||
"foo.example.com",
|
||||
);
|
||||
assert.equal(
|
||||
normalizeDomainInput("foo.example.com:8080/landing"),
|
||||
"foo.example.com:8080",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves the configured agent for a matching pageContext url", () => {
|
||||
assert.equal(
|
||||
resolveDomainMappedAgentId({
|
||||
pageUrl: "https://foo.example.com/article/123",
|
||||
mappings: [
|
||||
{
|
||||
id: "m1",
|
||||
domain: "foo.example.com",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
availableAgentIds: ["agent-1", "agent-2"],
|
||||
}),
|
||||
"agent-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores disabled mappings and unavailable agents", () => {
|
||||
assert.equal(
|
||||
resolveDomainMappedAgentId({
|
||||
pageUrl: "https://foo.example.com/article/123",
|
||||
mappings: [
|
||||
{
|
||||
id: "m1",
|
||||
domain: "foo.example.com",
|
||||
agentId: "agent-1",
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
domain: "bar.example.com",
|
||||
agentId: "agent-2",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
availableAgentIds: ["agent-3"],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not auto-select after manual selection or once a conversation has started", () => {
|
||||
assert.equal(
|
||||
resolveConfiguredIframeAgentId({
|
||||
pageUrl: "https://foo.example.com",
|
||||
mappings: [
|
||||
{
|
||||
id: "m1",
|
||||
domain: "foo.example.com",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
availableAgentIds: ["agent-1"],
|
||||
isNewThread: true,
|
||||
hasStartedConversation: false,
|
||||
manualSelectionLocked: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveConfiguredIframeAgentId({
|
||||
pageUrl: "https://foo.example.com",
|
||||
mappings: [
|
||||
{
|
||||
id: "m1",
|
||||
domain: "foo.example.com",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
availableAgentIds: ["agent-1"],
|
||||
isNewThread: true,
|
||||
hasStartedConversation: true,
|
||||
manualSelectionLocked: false,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to default assistant when url is unmapped and user has not manually selected an agent", () => {
|
||||
assert.deepEqual(
|
||||
resolveIncomingIframeSelection({
|
||||
pageUrl: "https://unknown.example.com",
|
||||
mappings: [
|
||||
{
|
||||
id: "m1",
|
||||
domain: "foo.example.com",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
availableAgentIds: ["agent-1", "agent-2"],
|
||||
manualSelectedAgentId: null,
|
||||
}),
|
||||
{
|
||||
selectedAgentId: null,
|
||||
forceDefaultAssistant: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to the user's manual selection when url is unmapped", () => {
|
||||
assert.deepEqual(
|
||||
resolveIncomingIframeSelection({
|
||||
pageUrl: "https://unknown.example.com",
|
||||
mappings: [
|
||||
{
|
||||
id: "m1",
|
||||
domain: "foo.example.com",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
availableAgentIds: ["agent-1", "agent-2"],
|
||||
manualSelectedAgentId: "agent-2",
|
||||
}),
|
||||
{
|
||||
selectedAgentId: "agent-2",
|
||||
forceDefaultAssistant: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("mapped domain overrides the user's manual selection for that incoming message", () => {
|
||||
assert.deepEqual(
|
||||
resolveIncomingIframeSelection({
|
||||
pageUrl: "https://foo.example.com/article",
|
||||
mappings: [
|
||||
{
|
||||
id: "m1",
|
||||
domain: "foo.example.com",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
availableAgentIds: ["agent-1", "agent-2"],
|
||||
manualSelectedAgentId: "agent-2",
|
||||
}),
|
||||
{
|
||||
selectedAgentId: "agent-1",
|
||||
forceDefaultAssistant: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("unmapped incoming url resets auto-selected agent back to default when there is no manual selection", () => {
|
||||
assert.deepEqual(
|
||||
resolveIncomingIframeSelection({
|
||||
pageUrl: "https://another-unknown.example.com/article",
|
||||
mappings: [
|
||||
{
|
||||
id: "m1",
|
||||
domain: "foo.example.com",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
availableAgentIds: ["agent-1", "agent-2"],
|
||||
manualSelectedAgentId: null,
|
||||
}),
|
||||
{
|
||||
selectedAgentId: null,
|
||||
forceDefaultAssistant: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
export type DomainAgentMapping = {
|
||||
id: string;
|
||||
domain: string;
|
||||
agentId: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
function trimToUndefined(value: string | null | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeAgentId(agentId: string | null | undefined): string | undefined {
|
||||
const trimmed = agentId?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function normalizeDomainInput(
|
||||
value: string | null | undefined,
|
||||
): string | undefined {
|
||||
const trimmed = trimToUndefined(value);
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
const host = parsed.host.trim().toLowerCase();
|
||||
return host || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDomainMappedAgentId({
|
||||
pageUrl,
|
||||
mappings,
|
||||
availableAgentIds,
|
||||
}: {
|
||||
pageUrl: string | null | undefined;
|
||||
mappings: DomainAgentMapping[];
|
||||
availableAgentIds?: readonly string[] | null;
|
||||
}): string | undefined {
|
||||
const host = normalizeDomainInput(pageUrl);
|
||||
if (!host) return undefined;
|
||||
|
||||
const allowedAgents =
|
||||
availableAgentIds && availableAgentIds.length > 0
|
||||
? new Set(availableAgentIds)
|
||||
: null;
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!mapping.enabled) continue;
|
||||
if (normalizeDomainInput(mapping.domain) !== host) continue;
|
||||
if (allowedAgents && !allowedAgents.has(mapping.agentId)) continue;
|
||||
return mapping.agentId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveConfiguredIframeAgentId({
|
||||
pageUrl,
|
||||
mappings,
|
||||
availableAgentIds,
|
||||
isNewThread,
|
||||
hasStartedConversation,
|
||||
manualSelectionLocked,
|
||||
}: {
|
||||
pageUrl: string | null | undefined;
|
||||
mappings: DomainAgentMapping[];
|
||||
availableAgentIds?: readonly string[] | null;
|
||||
isNewThread: boolean;
|
||||
hasStartedConversation: boolean;
|
||||
manualSelectionLocked: boolean;
|
||||
}): string | undefined {
|
||||
if (!isNewThread || hasStartedConversation || manualSelectionLocked) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return resolveDomainMappedAgentId({
|
||||
pageUrl,
|
||||
mappings,
|
||||
availableAgentIds,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveIncomingIframeSelection({
|
||||
pageUrl,
|
||||
mappings,
|
||||
availableAgentIds,
|
||||
manualSelectedAgentId,
|
||||
}: {
|
||||
pageUrl: string | null | undefined;
|
||||
mappings: DomainAgentMapping[];
|
||||
availableAgentIds?: readonly string[] | null;
|
||||
manualSelectedAgentId: string | null;
|
||||
}): {
|
||||
selectedAgentId: string | null;
|
||||
forceDefaultAssistant: boolean;
|
||||
} {
|
||||
const mappedAgentId = resolveDomainMappedAgentId({
|
||||
pageUrl,
|
||||
mappings,
|
||||
availableAgentIds,
|
||||
});
|
||||
if (mappedAgentId) {
|
||||
return {
|
||||
selectedAgentId: mappedAgentId,
|
||||
forceDefaultAssistant: false,
|
||||
};
|
||||
}
|
||||
|
||||
const allowedAgents =
|
||||
availableAgentIds && availableAgentIds.length > 0
|
||||
? new Set(availableAgentIds)
|
||||
: null;
|
||||
const normalizedManualAgentId = normalizeAgentId(manualSelectedAgentId);
|
||||
if (
|
||||
normalizedManualAgentId &&
|
||||
(!allowedAgents || allowedAgents.has(normalizedManualAgentId))
|
||||
) {
|
||||
return {
|
||||
selectedAgentId: normalizedManualAgentId,
|
||||
forceDefaultAssistant: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
selectedAgentId: null,
|
||||
forceDefaultAssistant: true,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ function makeSources(count: number): RuntimeDisplaySource[] {
|
||||
index: position + 1,
|
||||
skillName: "knowledge-base-search",
|
||||
mode: "citation",
|
||||
title: `参考文献 ${position + 1}`,
|
||||
title: `Reference ${position + 1}`,
|
||||
type: "result",
|
||||
snippet: "",
|
||||
raw: {},
|
||||
@@ -19,7 +19,7 @@ function citation(index: number): string {
|
||||
return `[citation:${index}](#reference-${index})`;
|
||||
}
|
||||
|
||||
test("全角单编号 【1】【2】 解析为引用徽章", () => {
|
||||
test("converts full-width numbered references", () => {
|
||||
const out = applyInlineCitations("结论见【1】【2】。", makeSources(2));
|
||||
assert.equal(out.includes(citation(1)), true);
|
||||
assert.equal(out.includes(citation(2)), true);
|
||||
@@ -27,21 +27,21 @@ test("全角单编号 【1】【2】 解析为引用徽章", () => {
|
||||
assert.equal(out.includes("【2】"), false);
|
||||
});
|
||||
|
||||
test("全角数字 【1】 也能识别", () => {
|
||||
test("converts full-width digits", () => {
|
||||
const out = applyInlineCitations("参考【1】和【2】。", makeSources(2));
|
||||
assert.equal(out.includes(citation(1)), true);
|
||||
assert.equal(out.includes(citation(2)), true);
|
||||
});
|
||||
|
||||
test("全角括号合并编号 【1,2】/【1,2】/【1、2】 拆成多个引用", () => {
|
||||
for (const marker of ["【1,2】", "【1,2】", "【1、2】"]) {
|
||||
test("splits merged reference markers", () => {
|
||||
for (const marker of ["【1,2】", "【1、2】", "【1,2】"]) {
|
||||
const out = applyInlineCitations(`见${marker}。`, makeSources(2));
|
||||
assert.equal(out.includes(citation(1)), true, marker);
|
||||
assert.equal(out.includes(citation(2)), true, marker);
|
||||
}
|
||||
});
|
||||
|
||||
test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => {
|
||||
test("expands reference ranges", () => {
|
||||
const dash = applyInlineCitations("见【1-3】。", makeSources(3));
|
||||
assert.equal(dash.includes(citation(1)), true);
|
||||
assert.equal(dash.includes(citation(2)), true);
|
||||
@@ -53,21 +53,26 @@ test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => {
|
||||
assert.equal(tilde.includes(citation(4)), true);
|
||||
});
|
||||
|
||||
test("超出来源范围的全角编号保持原样,不误判正文", () => {
|
||||
// 只有 1 条来源,【3】 没有对应来源 → 不应转换
|
||||
test("does not rewrite out-of-range references", () => {
|
||||
const out = applyInlineCitations("正文【3】不是引用。", makeSources(1));
|
||||
assert.equal(out.includes("【3】"), true);
|
||||
assert.equal(out.includes("#reference-3"), false);
|
||||
});
|
||||
|
||||
test("非数字的全角括号不被改写", () => {
|
||||
test("does not rewrite non-numeric bracket text", () => {
|
||||
const out = applyInlineCitations("见【附录】小节。", makeSources(2));
|
||||
assert.equal(out.includes("【附录】"), true);
|
||||
});
|
||||
|
||||
test("半角写法仍然正常工作", () => {
|
||||
test("keeps half-width syntax working", () => {
|
||||
const out = applyInlineCitations("见[1][2]和[1,3]。", makeSources(3));
|
||||
assert.equal(out.includes(citation(1)), true);
|
||||
assert.equal(out.includes(citation(2)), true);
|
||||
assert.equal(out.includes(citation(3)), true);
|
||||
});
|
||||
|
||||
test("accepts spaced and full-width square brackets", () => {
|
||||
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
|
||||
assert.equal(out.includes(citation(1)), true);
|
||||
assert.equal(out.includes(citation(2)), true);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
|
||||
import { buildSkillDisplayMap, collectRuntimeDisplaySources } from "./runtime";
|
||||
|
||||
function makeSkill(): Skill {
|
||||
return {
|
||||
name: "custom-search",
|
||||
description: "test",
|
||||
category: "test",
|
||||
license: "internal",
|
||||
enabled: true,
|
||||
display: {
|
||||
enabled: true,
|
||||
mode: "citation",
|
||||
resultPath: "docs",
|
||||
titleField: "title",
|
||||
summaryField: "summary",
|
||||
contentField: "content",
|
||||
sourceField: "source",
|
||||
authorField: null,
|
||||
typeField: null,
|
||||
timeField: null,
|
||||
scoreField: null,
|
||||
urlField: "url",
|
||||
idField: "id",
|
||||
linkStrategy: "auto",
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: 20,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
resultSets: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessages(toolContent: Message["content"]): Message[] {
|
||||
return [
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [{ id: "call-1", name: "custom-search", args: {} }],
|
||||
} as unknown as Message,
|
||||
{
|
||||
id: "tool-1",
|
||||
type: "tool",
|
||||
name: "custom-search",
|
||||
tool_call_id: "call-1",
|
||||
content: toolContent,
|
||||
} as unknown as Message,
|
||||
];
|
||||
}
|
||||
|
||||
test("collects citations from fenced nested json", () => {
|
||||
const messages = makeMessages(
|
||||
"```json\n{\"payload\":{\"docs\":[{\"id\":\"doc-1\",\"title\":\"Alpha\",\"summary\":\"Brief\",\"content\":\"Body\",\"source\":\"KB\",\"url\":\"https://example.com/a\"}]}}\n```",
|
||||
);
|
||||
const sources = collectRuntimeDisplaySources(
|
||||
messages,
|
||||
buildSkillDisplayMap([makeSkill()]),
|
||||
);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0]?.title, "Alpha");
|
||||
assert.equal(sources[0]?.url, "https://example.com/a");
|
||||
});
|
||||
|
||||
test("collects citations from structured tool payload arrays", () => {
|
||||
const messages = makeMessages([
|
||||
{
|
||||
type: "json",
|
||||
json: {
|
||||
wrapper: {
|
||||
docs: [
|
||||
{
|
||||
id: "doc-2",
|
||||
title: "Beta",
|
||||
summary: "Second",
|
||||
content: "Payload",
|
||||
source: "Skill",
|
||||
url: "https://example.com/b",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as Message["content"]);
|
||||
const sources = collectRuntimeDisplaySources(
|
||||
messages,
|
||||
buildSkillDisplayMap([makeSkill()]),
|
||||
);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0]?.title, "Beta");
|
||||
assert.equal(sources[0]?.source, "Skill");
|
||||
});
|
||||
@@ -588,7 +588,7 @@ function replaceBracketCitationTokens(
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
orderedSources: RuntimeDisplaySource[],
|
||||
): string {
|
||||
const tokenPattern = /\\?\[(\d{1,3})\\?\](?!\()/g;
|
||||
const tokenPattern = /\\?\[\s*([0-9\uFF10-\uFF19]{1,3})\s*\\?\](?!\()/g;
|
||||
let cursor = 0;
|
||||
let next = "";
|
||||
for (const match of content.matchAll(tokenPattern)) {
|
||||
@@ -598,13 +598,24 @@ function replaceBracketCitationTokens(
|
||||
continue;
|
||||
}
|
||||
next += content.slice(cursor, start);
|
||||
next += citationMarkdown(match[0], Number(match[1]), byIndex, orderedSources);
|
||||
next += citationMarkdown(
|
||||
match[0],
|
||||
Number(normalizeAsciiDigits(match[1] ?? "")),
|
||||
byIndex,
|
||||
orderedSources,
|
||||
);
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
next += content.slice(cursor);
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeAsciiDigits(value: string): string {
|
||||
return value.replace(/[\uFF10-\uFF19]/g, (digit) =>
|
||||
String.fromCharCode(digit.charCodeAt(0) - 0xfee0),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeLooseCitationTokens(
|
||||
content: string,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
@@ -637,13 +648,17 @@ function normalizeLooseCitationTokens(
|
||||
// 避免把正文里的【…】误判成引用。转成半角后交给下面的半角逻辑统一处理。
|
||||
|
||||
// 全角单编号:【1】/【1】
|
||||
let next = content.replace(
|
||||
let next = content
|
||||
.replace(/[/g, "[")
|
||||
.replace(/]/g, "]")
|
||||
.replace(/[,、]/g, ",")
|
||||
.replace(
|
||||
/【\s*([0-90-9]{1,3})\s*】/g,
|
||||
(match, rawIndex: string) => {
|
||||
const index = Number(toAsciiDigits(rawIndex));
|
||||
const index = Number(normalizeAsciiDigits(rawIndex));
|
||||
return isValidIndex(index) ? `[${index}]` : match;
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
// 全角合并编号:【1,2】/【1,2】/【1、2】
|
||||
next = next.replace(
|
||||
@@ -651,7 +666,7 @@ function normalizeLooseCitationTokens(
|
||||
(match, rawList: string) => {
|
||||
const indexes = rawList
|
||||
.split(/[,,、]/)
|
||||
.map((part) => Number(toAsciiDigits(part.trim())));
|
||||
.map((part) => Number(normalizeAsciiDigits(part.trim())));
|
||||
if (indexes.some((index) => !isValidIndex(index))) {
|
||||
return match;
|
||||
}
|
||||
@@ -663,8 +678,8 @@ function normalizeLooseCitationTokens(
|
||||
next = next.replace(
|
||||
/【\s*([0-90-9]{1,3})\s*[-–~~]\s*([0-90-9]{1,3})\s*】/g,
|
||||
(match, rawStart: string, rawEnd: string) => {
|
||||
const start = Number(toAsciiDigits(rawStart));
|
||||
const end = Number(toAsciiDigits(rawEnd));
|
||||
const start = Number(normalizeAsciiDigits(rawStart));
|
||||
const end = Number(normalizeAsciiDigits(rawEnd));
|
||||
if (
|
||||
end <= start ||
|
||||
end - start > 20 ||
|
||||
@@ -978,20 +993,23 @@ function extractStructuredToolPayload(content: unknown): unknown {
|
||||
}
|
||||
|
||||
function unwrapToolPayloadObject(value: Record<string, unknown>): unknown {
|
||||
if (
|
||||
Array.isArray(value.results) ||
|
||||
Array.isArray(value.data) ||
|
||||
Array.isArray(value.items)
|
||||
) {
|
||||
return value;
|
||||
const directCandidate = findDisplayPayloadCandidate(value, 0);
|
||||
if (directCandidate !== undefined) {
|
||||
return directCandidate;
|
||||
}
|
||||
for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) {
|
||||
const nested = value[key];
|
||||
for (const nested of Object.values(value)) {
|
||||
if (typeof nested === "string") {
|
||||
const parsed = parseJSONLikeText(nested);
|
||||
if (parsed !== undefined) {
|
||||
if (isRecord(parsed)) {
|
||||
const structured = findDisplayPayloadCandidate(parsed, 0);
|
||||
if (structured !== undefined) {
|
||||
return structured;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isRecord(nested)) {
|
||||
const unwrapped = unwrapToolPayloadObject(nested);
|
||||
@@ -1008,6 +1026,14 @@ function parseJSONLikeText(text: string): unknown {
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const fenced = extractFencedJSON(trimmed);
|
||||
if (fenced) {
|
||||
try {
|
||||
return JSON.parse(fenced) as unknown;
|
||||
} catch {
|
||||
// Fall through to best-effort parsing.
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
@@ -1016,17 +1042,9 @@ function parseJSONLikeText(text: string): unknown {
|
||||
}
|
||||
|
||||
function extractJSONFromText(text: string): unknown {
|
||||
const candidates: Array<[number, number]> = [];
|
||||
const firstObj = text.indexOf("{");
|
||||
if (firstObj !== -1) candidates.push([firstObj, text.lastIndexOf("}")]);
|
||||
const firstArr = text.indexOf("[");
|
||||
if (firstArr !== -1) candidates.push([firstArr, text.lastIndexOf("]")]);
|
||||
candidates.sort(([a], [b]) => a - b);
|
||||
for (const [start, end] of candidates) {
|
||||
if (start < 0 || end <= start) continue;
|
||||
const slice = text.slice(start, end + 1);
|
||||
for (const candidate of iterateBalancedJsonCandidates(text)) {
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
@@ -1034,6 +1052,108 @@ function extractJSONFromText(text: string): unknown {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function *iterateBalancedJsonCandidates(text: string): Generator<string> {
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (char !== "{" && char !== "[") {
|
||||
continue;
|
||||
}
|
||||
const candidate = extractBalancedJson(text, index);
|
||||
if (candidate) {
|
||||
yield candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractBalancedJson(text: string, start: number): string | null {
|
||||
const opening = text[start];
|
||||
if (opening !== "{" && opening !== "[") {
|
||||
return null;
|
||||
}
|
||||
const closing = opening === "{" ? "}" : "]";
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let index = start; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === "\"") {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "\"") {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (char === opening) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === closing) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return text.slice(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFencedJSON(text: string): string | null {
|
||||
const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
||||
return match?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function findDisplayPayloadCandidate(value: unknown, depth: number): unknown {
|
||||
if (depth > 8) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const objectItems = value.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
isRecord(item),
|
||||
);
|
||||
if (value.length === 0 || objectItems.length === value.length) {
|
||||
return { results: objectItems };
|
||||
}
|
||||
for (const item of value) {
|
||||
const nested = findDisplayPayloadCandidate(item, depth + 1);
|
||||
if (nested !== undefined) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
for (const key of ["results", "data", "items", "docs", "records", "rows", "list", "hits", "sources"]) {
|
||||
const nested = value[key];
|
||||
if (!Array.isArray(nested)) {
|
||||
continue;
|
||||
}
|
||||
const objectItems = nested.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
isRecord(item),
|
||||
);
|
||||
if (nested.length === 0 || objectItems.length === nested.length) {
|
||||
return { ...value, [key]: objectItems };
|
||||
}
|
||||
}
|
||||
for (const nested of Object.values(value)) {
|
||||
const candidate = findDisplayPayloadCandidate(nested, depth + 1);
|
||||
if (candidate !== undefined) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractSearchPayloadFromText(text: string): unknown {
|
||||
if (!text.includes('"results"') || !text.includes('"recUuid"')) {
|
||||
return undefined;
|
||||
|
||||
@@ -141,17 +141,39 @@ export async function getSkillDisplaySample(
|
||||
return response.json() as Promise<SkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
export interface InstallSkillRequest {
|
||||
thread_id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface InstallSkillResponse {
|
||||
success: boolean;
|
||||
skill_name: string;
|
||||
message: string;
|
||||
overwritten?: boolean;
|
||||
}
|
||||
export interface InstallSkillRequest {
|
||||
thread_id: string;
|
||||
path: string;
|
||||
skill_name?: string;
|
||||
}
|
||||
|
||||
export interface InstallSkillResponse {
|
||||
success: boolean;
|
||||
skill_name: string;
|
||||
message: string;
|
||||
overwritten?: boolean;
|
||||
}
|
||||
|
||||
export interface PreviewSkillInstallRequest {
|
||||
thread_id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PreviewSkillInstallResponse {
|
||||
ok: boolean;
|
||||
skill_name: string;
|
||||
description: string;
|
||||
license: string | null;
|
||||
allowed_tools: string[];
|
||||
can_install_as_is: boolean;
|
||||
owned_by_me: boolean;
|
||||
owned_by_other: boolean;
|
||||
builtin_conflict: boolean;
|
||||
owner_username?: string | null;
|
||||
owner_user_id?: string | null;
|
||||
message: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ValidateSkillUploadResponse {
|
||||
ok: boolean;
|
||||
@@ -164,9 +186,9 @@ export interface ValidateSkillUploadResponse {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export async function installSkill(
|
||||
request: InstallSkillRequest,
|
||||
): Promise<InstallSkillResponse> {
|
||||
export async function installSkill(
|
||||
request: InstallSkillRequest,
|
||||
): Promise<InstallSkillResponse> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -187,8 +209,24 @@ export async function installSkill(
|
||||
};
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function previewSkillInstall(
|
||||
request: PreviewSkillInstallRequest,
|
||||
): Promise<PreviewSkillInstallResponse> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install-preview`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to preview skill install");
|
||||
}
|
||||
return response.json() as Promise<PreviewSkillInstallResponse>;
|
||||
}
|
||||
|
||||
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Strategy 模块外部 iframe / 嵌入页链接(来自 Vite 环境变量) */
|
||||
|
||||
import { authStorageGet } from "@/core/auth/scoped-storage";
|
||||
import { readCurrentExternalAccessTokenValue } from "@/core/auth/external-access-token";
|
||||
|
||||
function trimTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
@@ -43,8 +44,7 @@ function readUserInfo(): Record<string, unknown> {
|
||||
|
||||
/** 当前登录态 accessToken(无则空串)。 */
|
||||
export function getAccessToken(): string {
|
||||
const value = readUserInfo().accessToken;
|
||||
return value == null ? "" : String(value);
|
||||
return readCurrentExternalAccessTokenValue(readUserInfo());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,12 +8,115 @@ export interface PromptPrefixSettings {
|
||||
prompt_prefix: string;
|
||||
}
|
||||
|
||||
export interface PluginDomainAgentMappingDto {
|
||||
id: string;
|
||||
domain: string;
|
||||
agent_id: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PluginManagementSettings {
|
||||
domainAgentMappings: Array<{
|
||||
id: string;
|
||||
domain: string;
|
||||
agentId: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PluginManagementUpdate {
|
||||
domainAgentMappings?: Array<{
|
||||
id: string;
|
||||
domain: string;
|
||||
agentId: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PromptPrefixUpdate {
|
||||
enabled?: boolean;
|
||||
switch_label?: string;
|
||||
prompt_prefix?: string;
|
||||
}
|
||||
|
||||
function fromPluginManagementDto(data: {
|
||||
domain_agent_mappings: PluginDomainAgentMappingDto[];
|
||||
}): PluginManagementSettings {
|
||||
return {
|
||||
domainAgentMappings: (data.domain_agent_mappings ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
domain: item.domain,
|
||||
agentId: item.agent_id,
|
||||
enabled: item.enabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function toPluginManagementDto(body: PluginManagementUpdate) {
|
||||
return {
|
||||
domain_agent_mappings: (body.domainAgentMappings ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
domain: item.domain,
|
||||
agent_id: item.agentId,
|
||||
enabled: item.enabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPluginManagementSettings(): Promise<PluginManagementSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/plugin-management`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load plugin management settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
domain_agent_mappings: PluginDomainAgentMappingDto[];
|
||||
};
|
||||
const mapped = fromPluginManagementDto(data);
|
||||
writeYLog({
|
||||
optType: OptType.view,
|
||||
optDetail: "CM助手-全量-系统设置-插件管理查看",
|
||||
url: "/api/system-settings/plugin-management",
|
||||
returnParam: mapped,
|
||||
});
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export async function updatePluginManagementSettings(
|
||||
body: PluginManagementUpdate,
|
||||
): Promise<PluginManagementSettings> {
|
||||
const payload = toPluginManagementDto(body);
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/plugin-management`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update plugin management settings" }));
|
||||
throw new Error(detail.detail ?? "Failed to update plugin management settings");
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
domain_agent_mappings: PluginDomainAgentMappingDto[];
|
||||
};
|
||||
const mapped = fromPluginManagementDto(data);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-系统设置-插件管理编辑",
|
||||
url: "/api/system-settings/plugin-management",
|
||||
reqParam: payload,
|
||||
returnParam: mapped,
|
||||
});
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export async function loadPromptPrefixSettings(): Promise<PromptPrefixSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
|
||||
@@ -176,12 +279,31 @@ export async function updatePublishSquares(
|
||||
export type ThemeMode = "light" | "dark" | "lightblue";
|
||||
export type ViewMode = "card" | "list";
|
||||
export type LayoutMode = "full" | "compact";
|
||||
export type CompactShortcutParamType = "login" | "theme";
|
||||
|
||||
export interface CompactShortcutRouteParam {
|
||||
key: string;
|
||||
paramType: CompactShortcutParamType;
|
||||
source?: string | null;
|
||||
customValue?: string | null;
|
||||
themeValue?: string | null;
|
||||
}
|
||||
|
||||
export interface CompactShortcutButton {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
route_params: CompactShortcutRouteParam[];
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface AppearanceSettings {
|
||||
default_theme: ThemeMode;
|
||||
default_skill_view: ViewMode;
|
||||
default_agent_view: ViewMode;
|
||||
default_layout_mode: LayoutMode;
|
||||
compact_shortcut_buttons: CompactShortcutButton[];
|
||||
}
|
||||
|
||||
export interface AppearanceUpdate {
|
||||
@@ -189,6 +311,7 @@ export interface AppearanceUpdate {
|
||||
default_skill_view?: ViewMode;
|
||||
default_agent_view?: ViewMode;
|
||||
default_layout_mode?: LayoutMode;
|
||||
compact_shortcut_buttons?: CompactShortcutButton[];
|
||||
}
|
||||
|
||||
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
||||
@@ -342,6 +465,18 @@ export interface SkillCompressionUpdate {
|
||||
keep_index_in_prompt?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelConcurrencySettings {
|
||||
enabled: boolean;
|
||||
default_limit: number;
|
||||
model_limits: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ModelConcurrencyUpdate {
|
||||
enabled?: boolean;
|
||||
default_limit?: number;
|
||||
model_limits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
||||
@@ -361,6 +496,53 @@ export async function loadSkillCompressionSettings(): Promise<SkillCompressionSe
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadModelConcurrencySettings(): Promise<ModelConcurrencySettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/model-concurrency`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load model concurrency settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as ModelConcurrencySettings;
|
||||
writeYLog({
|
||||
optType: OptType.view,
|
||||
optDetail: "CM助手-全量-系统设置-模型并发限制查看",
|
||||
url: "/api/system-settings/model-concurrency",
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateModelConcurrencySettings(
|
||||
body: ModelConcurrencyUpdate,
|
||||
): Promise<ModelConcurrencySettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/model-concurrency`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update model concurrency settings" }));
|
||||
throw new Error(detail.detail ?? "Failed to update model concurrency settings");
|
||||
}
|
||||
const data = (await response.json()) as ModelConcurrencySettings;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-系统设置-模型并发限制编辑",
|
||||
url: "/api/system-settings/model-concurrency",
|
||||
reqParam: body,
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSkillCompressionSettings(
|
||||
body: SkillCompressionUpdate,
|
||||
): Promise<SkillCompressionSettings> {
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
loadAppearanceSettings,
|
||||
loadCitationDisplaySettings,
|
||||
loadLeaderboardSettings,
|
||||
loadModelConcurrencySettings,
|
||||
loadPluginManagementSettings,
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
loadPublishSquares,
|
||||
@@ -15,6 +17,8 @@ import {
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updateLeaderboardSettings,
|
||||
updateModelConcurrencySettings,
|
||||
updatePluginManagementSettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
updatePublishSquares,
|
||||
@@ -22,6 +26,8 @@ import {
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type LeaderboardSettingsUpdate,
|
||||
type ModelConcurrencyUpdate,
|
||||
type PluginManagementUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
type PublishSquaresUpdate,
|
||||
@@ -37,6 +43,8 @@ const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
||||
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
||||
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
@@ -238,6 +246,48 @@ export function useUpdateSkillCompressionSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelConcurrencySettings() {
|
||||
return useQuery({
|
||||
queryKey: MODEL_CONCURRENCY_KEY,
|
||||
queryFn: () => loadModelConcurrencySettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateModelConcurrencySettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: ModelConcurrencyUpdate) =>
|
||||
updateModelConcurrencySettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(MODEL_CONCURRENCY_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePluginManagementSettings() {
|
||||
return useQuery({
|
||||
queryKey: PLUGIN_MANAGEMENT_KEY,
|
||||
queryFn: () => loadPluginManagementSettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePluginManagementSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: PluginManagementUpdate) =>
|
||||
updatePluginManagementSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(PLUGIN_MANAGEMENT_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLeaderboardSettings() {
|
||||
return useQuery({
|
||||
queryKey: LEADERBOARD_KEY,
|
||||
|
||||
@@ -43,9 +43,13 @@ export type ThreadStreamOptions = {
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
|
||||
title_provisional?: boolean;
|
||||
};
|
||||
|
||||
function normalizeStoredRunId(runId: string | null): string | null {
|
||||
if (!runId) {
|
||||
@@ -155,7 +159,7 @@ export async function stopThreadRun(
|
||||
}
|
||||
}
|
||||
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
}
|
||||
@@ -175,8 +179,31 @@ function getStreamErrorMessage(error: unknown): string {
|
||||
return nestedError;
|
||||
}
|
||||
}
|
||||
return "Request failed.";
|
||||
}
|
||||
return "Request failed.";
|
||||
}
|
||||
|
||||
async function fetchThreadTitleRefreshState(
|
||||
threadId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ThreadTitleRefreshState | null> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/state`,
|
||||
{ signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const json = (await response.json()) as {
|
||||
values?: { title?: string; title_provisional?: boolean };
|
||||
};
|
||||
if (!json.values) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: json.values.title ?? "",
|
||||
title_provisional: json.values.title_provisional,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect "thread not found" / forbidden errors from the LangGraph SDK.
|
||||
@@ -224,15 +251,24 @@ function isRunNotFoundError(message: string): boolean {
|
||||
* 「already has an active run」。匹配 HTTP 409、英文兜底文案,或后端已中文化
|
||||
* 的提示,统一映射成一句友好的中文。
|
||||
*/
|
||||
function isActiveRunConflictError(message: string): boolean {
|
||||
return (
|
||||
/\b409\b/.test(message) ||
|
||||
/already has an active run/i.test(message) ||
|
||||
/还在生成回答/.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCurrentUserLabel(): string {
|
||||
function isActiveRunConflictError(message: string): boolean {
|
||||
return (
|
||||
/\b409\b/.test(message) ||
|
||||
/already has an active run/i.test(message) ||
|
||||
/还在生成回答/.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
function isModelConcurrencyLimitError(message: string): boolean {
|
||||
return (
|
||||
/\b429\b/.test(message) ||
|
||||
/并发已达到上限/.test(message) ||
|
||||
/使用人数较多/.test(message) ||
|
||||
/too many requests/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCurrentUserLabel(): string {
|
||||
const auth = getStoredAuth();
|
||||
if (!auth) {
|
||||
return "未登录";
|
||||
@@ -307,15 +343,24 @@ function lastHumanFromChatModelInput(input: unknown): string {
|
||||
if (Array.isArray(m)) m.forEach(push);
|
||||
else if (m != null) flat.push(m);
|
||||
};
|
||||
push(raw);
|
||||
for (let i = flat.length - 1; i >= 0; i--) {
|
||||
const m = flat[i];
|
||||
if (!m || typeof m !== "object") continue;
|
||||
const type = (m as Record<string, unknown>).type ?? (m as Record<string, unknown>).role;
|
||||
if (type === "human" || type === "user") {
|
||||
const text = textFromChatModelOutput(m);
|
||||
if (text) return text;
|
||||
}
|
||||
push(raw);
|
||||
for (let i = flat.length - 1; i >= 0; i--) {
|
||||
const m = flat[i];
|
||||
if (!m || typeof m !== "object") continue;
|
||||
const obj = m as Record<string, unknown>;
|
||||
const type = obj.type ?? obj.role;
|
||||
if (type === "human" || type === "user") {
|
||||
const kwargs = obj.additional_kwargs;
|
||||
if (
|
||||
kwargs &&
|
||||
typeof kwargs === "object" &&
|
||||
(kwargs as Record<string, unknown>).hide_from_ui === true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const text = textFromChatModelOutput(m);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 取不到忽略 */
|
||||
@@ -570,12 +615,16 @@ export function useThreadStream({
|
||||
}
|
||||
// 上一轮还没结束又发了新问题(后端 409):给一句明确的中文提示,而不是
|
||||
// 把原始的英文/HTTP 错误抛给用户。
|
||||
if (isActiveRunConflictError(message)) {
|
||||
toast.info("当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。");
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
},
|
||||
if (isActiveRunConflictError(message)) {
|
||||
toast.info("当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。");
|
||||
return;
|
||||
}
|
||||
if (isModelConcurrencyLimitError(message)) {
|
||||
toast.info("当前使用人数较多,请稍等一下再试。");
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
},
|
||||
onFinish(state) {
|
||||
listeners.current.onFinish?.(state.values);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
@@ -583,9 +632,12 @@ export function useThreadStream({
|
||||
});
|
||||
|
||||
// Optimistic messages shown before the server stream responds
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const sendInFlightRef = useRef(false);
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [threadValueOverrides, setThreadValueOverrides] = useState<
|
||||
Partial<ThreadTitleRefreshState>
|
||||
>({});
|
||||
const sendInFlightRef = useRef(false);
|
||||
// Carries the ask time of an in-flight send until the server-assigned human
|
||||
// message id shows up, so we can bind the timestamp to the real id (see the
|
||||
// reconciliation effect below). ``known`` is the set of human ids that
|
||||
@@ -613,12 +665,13 @@ export function useThreadStream({
|
||||
// clearing optimistic messages in that case is exactly what made submitted
|
||||
// questions visually disappear — the live run that just started would be
|
||||
// aborted by the very effect intended to reset stale state.
|
||||
useEffect(() => {
|
||||
if (threadId != null) {
|
||||
// Forward direction: starting or continuing with a real thread.
|
||||
// No reset needed; the upstream useStream will load history for this id.
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (threadId != null) {
|
||||
// Forward direction: starting or continuing with a real thread.
|
||||
// No reset needed; the upstream useStream will load history for this id.
|
||||
setThreadValueOverrides({});
|
||||
return;
|
||||
}
|
||||
const prevThread = threadStreamRef.current;
|
||||
if (prevThread?.isLoading) {
|
||||
try {
|
||||
@@ -630,9 +683,88 @@ export function useThreadStream({
|
||||
startedRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
prevMsgCountRef.current = 0;
|
||||
setOptimisticMessages([]);
|
||||
setIsUploading(false);
|
||||
}, [threadId]);
|
||||
setOptimisticMessages([]);
|
||||
setIsUploading(false);
|
||||
setThreadValueOverrides({});
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||
const effectiveTitleProvisional =
|
||||
(threadValueOverrides.title_provisional ??
|
||||
thread.values?.title_provisional) === true;
|
||||
|
||||
if (!activeThreadId || thread.isLoading || !effectiveTitleProvisional) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
const poll = async () => {
|
||||
while (!cancelled) {
|
||||
try {
|
||||
const nextState = await fetchThreadTitleRefreshState(
|
||||
activeThreadId,
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelled || !nextState) {
|
||||
return;
|
||||
}
|
||||
|
||||
setThreadValueOverrides({
|
||||
title: nextState.title,
|
||||
title_provisional: nextState.title_provisional,
|
||||
});
|
||||
|
||||
if (nextState.title) {
|
||||
updateCachedThreadLists(queryClient, (threads) =>
|
||||
threads.map((t) =>
|
||||
t.thread_id === activeThreadId
|
||||
? {
|
||||
...t,
|
||||
values: {
|
||||
...t.values,
|
||||
title: nextState.title,
|
||||
title_provisional: nextState.title_provisional,
|
||||
},
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (nextState.title_provisional !== true) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
timeoutId = setTimeout(resolve, 2000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
onStreamThreadId,
|
||||
queryClient,
|
||||
thread.isLoading,
|
||||
thread.values?.title_provisional,
|
||||
threadValueOverrides.title_provisional,
|
||||
]);
|
||||
|
||||
// Clear optimistic when server messages arrive (count increases)
|
||||
useEffect(() => {
|
||||
@@ -897,13 +1029,22 @@ export function useThreadStream({
|
||||
);
|
||||
|
||||
// Merge thread with optimistic messages for display
|
||||
const mergedThread =
|
||||
optimisticMessages.length > 0
|
||||
? ({
|
||||
...thread,
|
||||
messages: [...thread.messages, ...optimisticMessages],
|
||||
} as typeof thread)
|
||||
: thread;
|
||||
const mergedValues =
|
||||
Object.keys(threadValueOverrides).length > 0
|
||||
? {
|
||||
...thread.values,
|
||||
...threadValueOverrides,
|
||||
}
|
||||
: thread.values;
|
||||
|
||||
const mergedThread =
|
||||
optimisticMessages.length > 0 || mergedValues !== thread.values
|
||||
? ({
|
||||
...thread,
|
||||
values: mergedValues,
|
||||
messages: [...thread.messages, ...optimisticMessages],
|
||||
} as typeof thread)
|
||||
: thread;
|
||||
|
||||
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
|
||||
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
|
||||
@@ -1271,28 +1412,29 @@ export function useRenameThread() {
|
||||
mutationFn: async ({
|
||||
threadId,
|
||||
title,
|
||||
}: {
|
||||
threadId: string;
|
||||
title: string;
|
||||
}) => {
|
||||
await apiClient.threads.updateState(threadId, {
|
||||
values: { title },
|
||||
});
|
||||
},
|
||||
onSuccess(_, { threadId, title }) {
|
||||
updateCachedThreadLists(queryClient, (threads) =>
|
||||
threads.map((t) =>
|
||||
}: {
|
||||
threadId: string;
|
||||
title: string;
|
||||
}) => {
|
||||
await apiClient.threads.updateState(threadId, {
|
||||
values: { title, title_provisional: false },
|
||||
});
|
||||
},
|
||||
onSuccess(_, { threadId, title }) {
|
||||
updateCachedThreadLists(queryClient, (threads) =>
|
||||
threads.map((t) =>
|
||||
t.thread_id === threadId
|
||||
? {
|
||||
...t,
|
||||
values: {
|
||||
...t.values,
|
||||
title,
|
||||
},
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
values: {
|
||||
...t.values,
|
||||
title,
|
||||
title_provisional: false,
|
||||
},
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Todo } from "../todos";
|
||||
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
title_provisional?: boolean;
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
|
||||
export interface AgentThreadContext extends Record<string, unknown> {
|
||||
thread_id: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { AIWritingRequest } from '../../types/ai-writing';
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
import { useWritingForm } from '../../contexts/WritingFormContext';
|
||||
import { extractAIWritingSample } from '../../api/ai-writing-sample';
|
||||
import { ResearchTemplatesPanel } from './ResearchTemplatesPanel';
|
||||
|
||||
interface AIWritingFormProps {
|
||||
onSubmit: (req: AIWritingRequest) => void;
|
||||
@@ -583,6 +584,8 @@ export function AIWritingForm({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResearchTemplatesPanel />
|
||||
|
||||
{!hideAdvancedFields && (
|
||||
<>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ChevronsDown, CloudUpload, PanelRightClose, Pause, Play, Plus, RotateCcw, Settings2, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -77,11 +77,13 @@ export function AIWritingPanel({
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
headerActionsBeforeCollapse,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
headerActionsBeforeCollapse?: ReactNode;
|
||||
} = {}) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -546,6 +548,7 @@ export function AIWritingPanel({
|
||||
onRename={rename}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
{headerActionsBeforeCollapse}
|
||||
{/* 收起写作配置:仅在 idle 配置阶段、表单展开时出现(与 AIWritingPage 的整块收起联动) */}
|
||||
{writingForm && isIdle && !isHistoryMode && writingForm.formExpanded && (
|
||||
<Button
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Eye,
|
||||
FileText,
|
||||
Globe2,
|
||||
History,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
Newspaper,
|
||||
PanelLeftClose,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -38,64 +28,11 @@ import { createSearchSpace } from "@/core/studio/api";
|
||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useResearchTemplates } from "../../hooks/useResearchTemplates";
|
||||
import { useAIWritingSessions } from "../../hooks/useAIWritingSessions";
|
||||
import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
|
||||
import type { AIWritingSessionSummary } from "../../api/ai-writing-sessions";
|
||||
import { useWritingForm } from "../../contexts/WritingFormContext";
|
||||
import { useAIWriting } from "../../contexts/AIWritingContext";
|
||||
import { useAIWritingHistory } from "../../contexts/AIWritingHistoryContext";
|
||||
import { formatRelative } from "./AIWritingHistoryDropdown";
|
||||
|
||||
type TemplateVisual = {
|
||||
Icon: LucideIcon;
|
||||
iconClass: string;
|
||||
accentClass: string;
|
||||
};
|
||||
|
||||
const TEMPLATE_VISUALS: TemplateVisual[] = [
|
||||
{
|
||||
Icon: Globe2,
|
||||
iconClass: "bg-blue-50 text-blue-600",
|
||||
accentClass: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
Icon: Newspaper,
|
||||
iconClass: "bg-amber-50 text-amber-600",
|
||||
accentClass: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
Icon: Sparkles,
|
||||
iconClass: "bg-violet-50 text-violet-600",
|
||||
accentClass: "bg-violet-500",
|
||||
},
|
||||
{
|
||||
Icon: TrendingUp,
|
||||
iconClass: "bg-rose-50 text-rose-600",
|
||||
accentClass: "bg-rose-500",
|
||||
},
|
||||
{
|
||||
Icon: BookOpen,
|
||||
iconClass: "bg-emerald-50 text-emerald-600",
|
||||
accentClass: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
Icon: BarChart3,
|
||||
iconClass: "bg-cyan-50 text-cyan-600",
|
||||
accentClass: "bg-cyan-500",
|
||||
},
|
||||
];
|
||||
|
||||
function getTemplateVisual(tpl: ResearchTemplate, index: number): TemplateVisual {
|
||||
const text = `${tpl.name} ${tpl.description ?? ""} ${tpl.article_type_key ?? ""}`;
|
||||
if (/网摘|网络|热点|摘/.test(text)) return TEMPLATE_VISUALS[0];
|
||||
if (/周报|周刊|日报|月报/.test(text)) return TEMPLATE_VISUALS[1];
|
||||
if (/要讯|资讯|快讯|简报/.test(text)) return TEMPLATE_VISUALS[2];
|
||||
if (/行业|分析|市场|竞争/.test(text)) return TEMPLATE_VISUALS[3];
|
||||
if (/政策|解读|法规/.test(text)) return TEMPLATE_VISUALS[4];
|
||||
return TEMPLATE_VISUALS[index % TEMPLATE_VISUALS.length];
|
||||
}
|
||||
|
||||
// 历史记录状态药丸样式(对齐多智能体会商的历史记录观感)。
|
||||
// 注:`in_progress` 不再转圈显示「进行中」——刷新=重置后这些会话已无任何活进程,
|
||||
// 转圈会误导用户以为还在跑;统一显示为中性、不动的「未完成」。真正还在跑的是 `background`
|
||||
@@ -237,25 +174,11 @@ export function ResearchSidebar({
|
||||
/** 收起侧栏(由页面承载收起态)。 */
|
||||
onCollapse?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { templates, refresh } = useResearchTemplates();
|
||||
const { startWriting } = useAIWriting();
|
||||
const {
|
||||
notebooks,
|
||||
isLoading: notebooksLoading,
|
||||
isError: notebooksError,
|
||||
} = useStudioNotebooks();
|
||||
const {
|
||||
setIntent,
|
||||
setArticleType,
|
||||
setOutlineText,
|
||||
setShowOutline,
|
||||
setMaterialSource,
|
||||
setWordCountInput,
|
||||
setAudience,
|
||||
setStrictMode,
|
||||
setFormExpanded,
|
||||
} = useWritingForm();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 「历史记录」列表(左下角,样式参考多智能体会商的历史记录):自带一份会话列表,
|
||||
@@ -283,26 +206,11 @@ export function ResearchSidebar({
|
||||
window.dispatchEvent(new CustomEvent("ai-writing:show-progress", { detail: session }));
|
||||
};
|
||||
|
||||
const [templateSearch, setTemplateSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const keyword = templateSearch.trim().toLowerCase();
|
||||
if (!keyword) return templates;
|
||||
return templates.filter((tpl) =>
|
||||
[tpl.name, tpl.description, tpl.article_type_key]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword)),
|
||||
);
|
||||
}, [templates, templateSearch]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -323,39 +231,6 @@ export function ResearchSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateClick = (tpl: ResearchTemplate) => {
|
||||
const intent = tpl.topic?.trim() ?? "";
|
||||
const articleType = tpl.article_type_key || "analysis";
|
||||
const outline = tpl.outline?.trim() || "";
|
||||
const audience = tpl.audience?.trim() || "普通读者";
|
||||
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
|
||||
const writingMode = tpl.strict_mode ? "strict" : "loose";
|
||||
|
||||
if (intent) setIntent(intent);
|
||||
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
|
||||
if (outline) {
|
||||
setOutlineText(outline);
|
||||
setShowOutline(true);
|
||||
}
|
||||
setMaterialSource(tpl.material_source);
|
||||
setWordCountInput(String(wordCount));
|
||||
if (tpl.audience?.trim()) setAudience(audience);
|
||||
setStrictMode(tpl.strict_mode);
|
||||
setFormExpanded(true);
|
||||
|
||||
if (!intent) return;
|
||||
void startWriting({
|
||||
userIntent: intent,
|
||||
userOutline: outline || undefined,
|
||||
articleType,
|
||||
targetAudience: audience,
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: 1,
|
||||
writingMode,
|
||||
materialSource: tpl.material_source,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="ai-research-sidebar flex h-full w-[320px] shrink-0 flex-col overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)]">
|
||||
{onCollapse && (
|
||||
@@ -373,91 +248,7 @@ export function ResearchSidebar({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4">
|
||||
<section className="mb-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/canvas/research-templates")}
|
||||
title="配置研究模板"
|
||||
aria-label="配置研究模板"
|
||||
className="group flex items-center gap-2 rounded-lg text-[16px] font-bold text-slate-500 transition-colors hover:text-slate-800"
|
||||
>
|
||||
<SlidersHorizontal className="size-4 text-slate-400" />
|
||||
<span>研究模板</span>
|
||||
<Settings2 className="size-3.5 text-slate-300 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
<div className="relative w-[150px]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={templateSearch}
|
||||
onChange={(event) => setTemplateSearch(event.target.value)}
|
||||
placeholder="搜索写作模板..."
|
||||
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50/80 pl-8 pr-3 text-[12px] text-slate-600 outline-none transition focus:border-blue-200 focus:bg-white focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
|
||||
暂无研究模板,点击标题配置
|
||||
</p>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
|
||||
暂无匹配模板
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredTemplates.map((tpl, index) => {
|
||||
const visual = getTemplateVisual(tpl, index);
|
||||
const Icon = visual.Icon;
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplateClick(tpl)}
|
||||
title={tpl.description ?? tpl.name}
|
||||
className="group relative flex w-full items-start gap-3 overflow-hidden rounded-[5px] bg-white px-4 py-4 text-left shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(30,64,175,0.08)] active:translate-y-0"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-12 shrink-0 items-center justify-center rounded-xl transition-colors",
|
||||
visual.iconClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6 stroke-[2.2]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 pt-0.5">
|
||||
<span className="block truncate text-[16px] font-bold leading-5 text-slate-900 transition-colors group-hover:text-blue-700">
|
||||
{tpl.name}
|
||||
</span>
|
||||
{tpl.description && (
|
||||
<span
|
||||
className="mt-1.5 overflow-hidden text-[13px] leading-6 text-slate-500"
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2,
|
||||
}}
|
||||
>
|
||||
{tpl.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-4 left-0 top-4 w-1 rounded-r-full opacity-0 transition-opacity group-hover:opacity-100",
|
||||
visual.accentClass,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col border-t border-slate-100 pt-5">
|
||||
<section className="flex flex-col">
|
||||
<div className="mb-3 flex items-center justify-between px-2">
|
||||
<h2 className="text-[15px] font-bold tracking-wide text-slate-400">
|
||||
笔记本
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Globe2,
|
||||
Newspaper,
|
||||
Search,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
|
||||
import { useAIWriting } from "../../contexts/AIWritingContext";
|
||||
import { useWritingForm } from "../../contexts/WritingFormContext";
|
||||
import { useResearchTemplates } from "../../hooks/useResearchTemplates";
|
||||
|
||||
type TemplateVisual = {
|
||||
Icon: LucideIcon;
|
||||
iconClass: string;
|
||||
accentClass: string;
|
||||
};
|
||||
|
||||
const TEMPLATE_VISUALS: TemplateVisual[] = [
|
||||
{
|
||||
Icon: Globe2,
|
||||
iconClass: "bg-blue-50 text-blue-600",
|
||||
accentClass: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
Icon: Newspaper,
|
||||
iconClass: "bg-amber-50 text-amber-600",
|
||||
accentClass: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
Icon: Sparkles,
|
||||
iconClass: "bg-violet-50 text-violet-600",
|
||||
accentClass: "bg-violet-500",
|
||||
},
|
||||
{
|
||||
Icon: TrendingUp,
|
||||
iconClass: "bg-rose-50 text-rose-600",
|
||||
accentClass: "bg-rose-500",
|
||||
},
|
||||
{
|
||||
Icon: BookOpen,
|
||||
iconClass: "bg-emerald-50 text-emerald-600",
|
||||
accentClass: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
Icon: BarChart3,
|
||||
iconClass: "bg-cyan-50 text-cyan-600",
|
||||
accentClass: "bg-cyan-500",
|
||||
},
|
||||
];
|
||||
|
||||
function getTemplateVisual(
|
||||
tpl: ResearchTemplate,
|
||||
index: number,
|
||||
): TemplateVisual {
|
||||
const text = `${tpl.name} ${tpl.description ?? ""} ${tpl.article_type_key ?? ""}`;
|
||||
if (/网摘|网络|热点|摘/.test(text)) return TEMPLATE_VISUALS[0];
|
||||
if (/周报|周刊|日报|月报/.test(text)) return TEMPLATE_VISUALS[1];
|
||||
if (/要讯|资讯|快讯|简报/.test(text)) return TEMPLATE_VISUALS[2];
|
||||
if (/行业|分析|市场|竞争/.test(text)) return TEMPLATE_VISUALS[3];
|
||||
if (/政策|解读|法规/.test(text)) return TEMPLATE_VISUALS[4];
|
||||
return TEMPLATE_VISUALS[index % TEMPLATE_VISUALS.length];
|
||||
}
|
||||
|
||||
export function ResearchTemplatesPanel({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { templates, refresh } = useResearchTemplates();
|
||||
const { startWriting } = useAIWriting();
|
||||
const {
|
||||
setIntent,
|
||||
setArticleType,
|
||||
setOutlineText,
|
||||
setShowOutline,
|
||||
setMaterialSource,
|
||||
setWordCountInput,
|
||||
setAudience,
|
||||
setStrictMode,
|
||||
setFormExpanded,
|
||||
} = useWritingForm();
|
||||
const [templateSearch, setTemplateSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const keyword = templateSearch.trim().toLowerCase();
|
||||
if (!keyword) return templates;
|
||||
return templates.filter((tpl) =>
|
||||
[tpl.name, tpl.description, tpl.article_type_key]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword)),
|
||||
);
|
||||
}, [templates, templateSearch]);
|
||||
|
||||
const handleTemplateClick = (tpl: ResearchTemplate) => {
|
||||
const intent = tpl.topic?.trim() ?? "";
|
||||
const articleType = tpl.article_type_key || "analysis";
|
||||
const outline = tpl.outline?.trim() || "";
|
||||
const audience = tpl.audience?.trim() || "普通读者";
|
||||
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
|
||||
const writingMode = tpl.strict_mode ? "strict" : "loose";
|
||||
|
||||
if (intent) setIntent(intent);
|
||||
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
|
||||
if (outline) {
|
||||
setOutlineText(outline);
|
||||
setShowOutline(true);
|
||||
}
|
||||
setMaterialSource(tpl.material_source);
|
||||
setWordCountInput(String(wordCount));
|
||||
if (tpl.audience?.trim()) setAudience(audience);
|
||||
setStrictMode(tpl.strict_mode);
|
||||
setFormExpanded(true);
|
||||
|
||||
if (!intent) return;
|
||||
void startWriting({
|
||||
userIntent: intent,
|
||||
userOutline: outline || undefined,
|
||||
articleType,
|
||||
targetAudience: audience,
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: 1,
|
||||
writingMode,
|
||||
materialSource: tpl.material_source,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"rounded-2xl border border-[#ece5e4] bg-card p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/canvas/research-templates")}
|
||||
title="配置研究模板"
|
||||
aria-label="配置研究模板"
|
||||
className="group flex items-center gap-2 rounded-lg text-[16px] font-bold text-slate-600 transition-colors hover:text-slate-900 dark:text-white"
|
||||
>
|
||||
<SlidersHorizontal className="size-4 text-slate-400 dark:text-white/70" />
|
||||
<span>研究模板</span>
|
||||
<Settings2 className="size-3.5 text-slate-300 opacity-0 transition-opacity group-hover:opacity-100 dark:text-white/60" />
|
||||
</button>
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-[180px]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={templateSearch}
|
||||
onChange={(event) => setTemplateSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") event.preventDefault();
|
||||
}}
|
||||
placeholder="搜索写作模板..."
|
||||
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50/80 pl-8 pr-3 text-[12px] text-slate-600 outline-none transition focus:border-blue-200 focus:bg-white focus:ring-2 focus:ring-blue-100 dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
|
||||
暂无研究模板,点击标题配置
|
||||
</p>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
|
||||
暂无匹配模板
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredTemplates.map((tpl, index) => {
|
||||
const visual = getTemplateVisual(tpl, index);
|
||||
const Icon = visual.Icon;
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplateClick(tpl)}
|
||||
title={tpl.description ?? tpl.name}
|
||||
className="group relative flex w-full items-start gap-3 overflow-hidden rounded-[5px] bg-white px-4 py-4 text-left shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(30,64,175,0.08)] active:translate-y-0 dark:bg-[#032650]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-12 shrink-0 items-center justify-center rounded-xl transition-colors",
|
||||
visual.iconClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6 stroke-[2.2]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 pt-0.5">
|
||||
<span className="block truncate text-[16px] font-bold leading-5 text-slate-900 transition-colors group-hover:text-blue-700 dark:text-white">
|
||||
{tpl.name}
|
||||
</span>
|
||||
{tpl.description && (
|
||||
<span
|
||||
className="mt-1.5 overflow-hidden text-[13px] leading-6 text-slate-500 dark:text-[#AACEFF]/75"
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2,
|
||||
}}
|
||||
>
|
||||
{tpl.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-4 left-0 top-4 w-1 rounded-r-full opacity-0 transition-opacity group-hover:opacity-100",
|
||||
visual.accentClass,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +81,7 @@
|
||||
* -d '{"retention_days": 0}'
|
||||
* ──────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
History,
|
||||
@@ -113,6 +113,10 @@ import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage';
|
||||
import StudioNotebooksPage from '@/pages/StudioNotebooksPage';
|
||||
import AgentChatPage from '@/pages/AgentChatPage';
|
||||
import { ChatRuntime } from '@/pages/WorkspaceRoutes';
|
||||
import {
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from '@/components/workspace/appearance-controls';
|
||||
import {
|
||||
AgentModuleSidebar,
|
||||
useAgentModuleSidebarData,
|
||||
@@ -137,10 +141,20 @@ const WRITING_ID = 'aw-writing';
|
||||
const DRAFT_ID = 'aw-draft';
|
||||
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
|
||||
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
|
||||
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
|
||||
|
||||
// Match the original canvas page proportions (chat=35, artifact=65)
|
||||
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
||||
|
||||
function AIWritingCompactAppearanceActions() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAIWritingRouteBase() {
|
||||
const { pathname } = useLocation();
|
||||
return useMemo(() => {
|
||||
@@ -170,7 +184,15 @@ function useAIWritingAgentRouteState(base: string) {
|
||||
|
||||
function AIWritingAgentDefaultRedirect() {
|
||||
const { groups, isLoading } = useAgentModuleSidebarData(AI_WRITING_AGENT_MODULE_KEY);
|
||||
const firstAgent = groups.flatMap((group) => group.agents)[0];
|
||||
const agents = groups.flatMap((group) => group.agents);
|
||||
const lastAgentId = (() => {
|
||||
try {
|
||||
return sessionStorage.getItem(AI_WRITING_LAST_AGENT_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const targetAgent = agents.find((agent) => agent.id === lastAgentId) ?? agents[0];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -180,7 +202,7 @@ function AIWritingAgentDefaultRedirect() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!firstAgent) {
|
||||
if (!targetAgent) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||
暂无可用智能体,请先在模块设置中配置或创建智能体。
|
||||
@@ -188,24 +210,44 @@ function AIWritingAgentDefaultRedirect() {
|
||||
);
|
||||
}
|
||||
|
||||
return <Navigate to={`agents/${firstAgent.id}/chats/new`} replace />;
|
||||
return <Navigate to={`agents/${targetAgent.id}/chats/new`} replace />;
|
||||
}
|
||||
|
||||
function AIWritingAgentChatRoute() {
|
||||
function AIWritingAgentChatRoute({
|
||||
compactAppearanceActions,
|
||||
}: {
|
||||
compactAppearanceActions?: ReactNode;
|
||||
}) {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
return (
|
||||
<ChatRuntime>
|
||||
<AgentChatPage key={agent_id} />
|
||||
<AgentChatPage
|
||||
key={agent_id}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
</ChatRuntime>
|
||||
);
|
||||
}
|
||||
|
||||
function AIWritingAgentRoutes({ base }: { base: string }) {
|
||||
function AIWritingAgentRoutes({
|
||||
base,
|
||||
compactAppearanceActions,
|
||||
}: {
|
||||
base: string;
|
||||
compactAppearanceActions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<RouteBaseProvider value={base}>
|
||||
<Routes>
|
||||
<Route index element={<AIWritingAgentDefaultRedirect />} />
|
||||
<Route path="agents/:agent_id/chats/:thread_id" element={<AIWritingAgentChatRoute />} />
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<AIWritingAgentChatRoute
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
@@ -220,11 +262,26 @@ interface NotebookProps {
|
||||
studioToken?: string;
|
||||
}
|
||||
|
||||
function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
|
||||
function WritingFormPanel({
|
||||
hideAdvancedFields,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
headerActionsBeforeCollapse,
|
||||
}: {
|
||||
hideAdvancedFields: boolean;
|
||||
headerActionsBeforeCollapse?: ReactNode;
|
||||
} & NotebookProps) {
|
||||
return (
|
||||
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
<AIWritingPanel
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
headerActionsBeforeCollapse={headerActionsBeforeCollapse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -234,7 +291,16 @@ function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocume
|
||||
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
|
||||
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
|
||||
*/
|
||||
function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
|
||||
function AIWritingLayout({
|
||||
hideAdvancedFields,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
compactAppearanceActions,
|
||||
}: {
|
||||
hideAdvancedFields: boolean;
|
||||
compactAppearanceActions?: ReactNode;
|
||||
} & NotebookProps) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const { state } = useAIWriting();
|
||||
const { isHistoryMode } = useAIWritingHistory();
|
||||
@@ -262,19 +328,28 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
|
||||
formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px] border-l' : 'w-0 min-w-0',
|
||||
)}
|
||||
>
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
<WritingFormPanel
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
headerActionsBeforeCollapse={compactAppearanceActions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 收起态:右上角浮动展开图标按钮(与展开态头部的收起按钮位置呼应) */}
|
||||
{!formExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormExpanded(true)}
|
||||
title="展开写作配置"
|
||||
className="absolute right-3 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<PanelRightOpen className="size-4" />
|
||||
</button>
|
||||
<div className="absolute right-3 top-2 z-10 flex items-center gap-1">
|
||||
{compactAppearanceActions}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormExpanded(true)}
|
||||
title="展开写作配置"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<PanelRightOpen className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -322,7 +397,16 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
|
||||
* 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态:
|
||||
* 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。
|
||||
*/
|
||||
function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
|
||||
function AIWritingBody({
|
||||
hideAdvancedFields,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
compactChrome = false,
|
||||
}: {
|
||||
hideAdvancedFields: boolean;
|
||||
compactChrome?: boolean;
|
||||
} & NotebookProps) {
|
||||
const { state } = useAIWriting();
|
||||
const { isHistoryMode } = useAIWritingHistory();
|
||||
const location = useLocation();
|
||||
@@ -330,6 +414,8 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
const routeBase = useAIWritingRouteBase();
|
||||
const agentRoute = useAIWritingAgentRouteState(routeBase);
|
||||
const isAdmin = getStoredAuth()?.system_role === 'admin';
|
||||
const externalNotebookMode =
|
||||
studioToken != null || initialNotebookId != null || initialDocumentIds != null;
|
||||
|
||||
// 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容
|
||||
// (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。
|
||||
@@ -348,12 +434,15 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
}
|
||||
}, [agentRoute.active]);
|
||||
|
||||
const agentsActive = sidebarTab === 'agents';
|
||||
const agentsActive = !externalNotebookMode && sidebarTab === 'agents';
|
||||
|
||||
// 研究模板侧栏在「写作配置(idle)」与「历史回看」两态都展示(点历史记录后保留侧栏、
|
||||
// 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。
|
||||
const showSidebar =
|
||||
!studioToken && (state.status === 'idle' || isHistoryMode || agentsActive);
|
||||
!externalNotebookMode && (state.status === 'idle' || isHistoryMode || agentsActive);
|
||||
const compactAppearanceActions = compactChrome ? (
|
||||
<AIWritingCompactAppearanceActions />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex">
|
||||
@@ -361,7 +450,7 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
{showSidebar && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full shrink-0 overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)] transition-[width] duration-300 ease-in-out',
|
||||
'relative h-full shrink-0 overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)] transition-[width] duration-300 ease-in-out dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:text-[#c5cfe0] dark:shadow-none dark:backdrop-blur-md',
|
||||
sidebarCollapsed ? 'w-11' : 'w-[320px]',
|
||||
)}
|
||||
>
|
||||
@@ -376,11 +465,11 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
title="展开侧栏"
|
||||
aria-label="展开侧栏"
|
||||
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700"
|
||||
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]"
|
||||
>
|
||||
<PanelLeftOpen className="size-4" />
|
||||
</button>
|
||||
<History className="size-4 text-slate-300" />
|
||||
<History className="size-4 text-slate-300 dark:text-[#6e88b0]" />
|
||||
</div>
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -388,8 +477,8 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
sidebarCollapsed ? 'pointer-events-none -translate-x-4 opacity-0' : 'translate-x-0 opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-slate-100 px-3 py-2">
|
||||
<div className="grid flex-1 grid-cols-2 rounded-lg bg-slate-100 p-1">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-slate-100 px-3 py-2 dark:border-[#2b8ffe]/20">
|
||||
<div className="grid flex-1 grid-cols-2 rounded-lg bg-slate-100 p-1 dark:bg-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -400,8 +489,8 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
|
||||
sidebarTab === 'agents'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800',
|
||||
? 'bg-white text-slate-900 shadow-sm dark:bg-[#122347]/90 dark:text-[#e8edf5] dark:shadow-none'
|
||||
: 'text-slate-500 hover:text-slate-800 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]',
|
||||
)}
|
||||
>
|
||||
课题研究
|
||||
@@ -416,8 +505,8 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
|
||||
sidebarTab === 'research'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800',
|
||||
? 'bg-white text-slate-900 shadow-sm dark:bg-[#122347]/90 dark:text-[#e8edf5] dark:shadow-none'
|
||||
: 'text-slate-500 hover:text-slate-800 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]',
|
||||
)}
|
||||
>
|
||||
ai写作
|
||||
@@ -428,7 +517,7 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
title="收起侧栏"
|
||||
aria-label="收起侧栏"
|
||||
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700"
|
||||
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]"
|
||||
>
|
||||
<PanelLeftClose className="size-4" />
|
||||
</button>
|
||||
@@ -459,7 +548,13 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<div className={cn('absolute inset-0', (overlayActive || agentsActive) && 'hidden')}>
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
<AIWritingLayout
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
</div>
|
||||
{overlayActive && !agentsActive && (
|
||||
<div className="absolute inset-0 flex flex-col bg-background">
|
||||
@@ -499,8 +594,11 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
|
||||
</div>
|
||||
)}
|
||||
{agentsActive && (
|
||||
<div className="absolute inset-0 bg-background">
|
||||
<AIWritingAgentRoutes base={routeBase} />
|
||||
<div className="workspace-chat-bg absolute inset-0">
|
||||
<AIWritingAgentRoutes
|
||||
base={routeBase}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -521,11 +619,13 @@ export default function AIWritingPage({
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
compactChrome = false,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
compactChrome?: boolean;
|
||||
} = {}) {
|
||||
return (
|
||||
<CanvasThreadProvider>
|
||||
@@ -544,6 +644,7 @@ export default function AIWritingPage({
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
compactChrome={compactChrome}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -50,13 +50,72 @@ import {
|
||||
type TemplateRenderResult,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { env } from "@/env";
|
||||
import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose";
|
||||
import {
|
||||
readTaskContext,
|
||||
taskThreadMetadata,
|
||||
type TaskContext,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { buildTaskOpeningText, readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
import {
|
||||
buildTaskOpeningText,
|
||||
readActionDetail,
|
||||
readCopTaskDetail,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
import type { RwfxSqGateStartContext } from "@/strategy-components/components/task-components/rwfx-sq-gate";
|
||||
|
||||
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
|
||||
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
|
||||
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
|
||||
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
|
||||
|
||||
function stringifyForHiddenContext(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
const text = JSON.stringify(value, null, 2);
|
||||
return typeof text === "string" ? text : "";
|
||||
} catch {
|
||||
return String(value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
function truncateMiddle(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
const marker = "\n\n...[内容过长,已截断中间部分]...\n\n";
|
||||
const tailChars = Math.min(RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS, Math.floor(maxChars / 4));
|
||||
const headChars = Math.max(0, maxChars - marker.length - tailChars);
|
||||
return `${text.slice(0, headChars)}${marker}${text.slice(-tailChars)}`;
|
||||
}
|
||||
|
||||
function compactSqReportForContext(report: unknown): unknown {
|
||||
if (report == null) return null;
|
||||
const serialized = stringifyForHiddenContext(report);
|
||||
if (serialized.length <= RWFX_SQ_REPORT_CONTEXT_MAX_CHARS) return report;
|
||||
return {
|
||||
truncated: true,
|
||||
originalChars: serialized.length,
|
||||
maxChars: RWFX_SQ_REPORT_CONTEXT_MAX_CHARS,
|
||||
content: truncateMiddle(serialized, RWFX_SQ_REPORT_CONTEXT_MAX_CHARS),
|
||||
};
|
||||
}
|
||||
|
||||
function rwfxSqReportRunContext(
|
||||
gateContext?: RwfxSqGateStartContext,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!gateContext?.sqReportStatus) return undefined;
|
||||
return {
|
||||
task_external_context: {
|
||||
kind: "rwfx_sq_report",
|
||||
status: gateContext.sqReportStatus,
|
||||
title: "三情分析(3q)研判情报报告",
|
||||
usage:
|
||||
gateContext.sqReportStatus === "complete"
|
||||
? "这是任务分析(rwfx)问答的隐藏业务上下文。回答时应参考这些已完成的 3q 结果;如 data.truncated=true,说明内容已截断,只能基于可见片段分析。不要向用户说明页面注入了隐藏上下文,除非用户明确询问。"
|
||||
: "3q 研判情报报告当前未提供有效结果。回答时不要编造 3q 结果,可仅基于任务信息继续分析。",
|
||||
data: compactSqReportForContext(gateContext.sqReport),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function AgentChatPage({
|
||||
embeddedAgentId,
|
||||
@@ -65,6 +124,7 @@ export default function AgentChatPage({
|
||||
taskBind,
|
||||
autoStartWhenPurposeEmpty,
|
||||
showAgentList,
|
||||
compactAppearanceActions,
|
||||
}: {
|
||||
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
|
||||
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
|
||||
@@ -85,6 +145,8 @@ export default function AgentChatPage({
|
||||
/** 是否在最左侧展示「智能体列表」栏(顶部下拉切换 我的/广场,点击进入该智能体对话)。
|
||||
* 仅独立的智能体问答整页开启;任务工作区 / 模块页 / 嵌入态不传(各有自己的左栏)。 */
|
||||
showAgentList?: boolean;
|
||||
/** 简洁模式下,挂到右侧智能体信息面板收起按钮左侧的外观控件。 */
|
||||
compactAppearanceActions?: ReactNode;
|
||||
} = {}) {
|
||||
const routeBase = useRouteBase();
|
||||
const { t } = useI18n();
|
||||
@@ -101,7 +163,8 @@ export default function AgentChatPage({
|
||||
|
||||
// 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。
|
||||
const taskCtxOn = !!(taskGate || taskBind);
|
||||
const taskContext = useMemo(() => {
|
||||
const isXdfxTaskBind = !!chatBasePath?.includes("/plan-task-action/");
|
||||
const taskContext = useMemo<TaskContext | null>(() => {
|
||||
if (!taskCtxOn) return null;
|
||||
const stored = readTaskContext();
|
||||
if (stored && stored.agentId === agent_id) return stored;
|
||||
@@ -111,15 +174,28 @@ export default function AgentChatPage({
|
||||
const detail = readCopTaskDetail();
|
||||
const fallbackTaskId = detail?.id == null ? "" : String(detail.id).trim();
|
||||
if (detail && fallbackTaskId && agent_id) {
|
||||
const actionDetail = isXdfxTaskBind ? readActionDetail() : null;
|
||||
const actionId = actionDetail?.id ? String(actionDetail.id).trim() : "";
|
||||
const actionTaskId = actionDetail?.taskId ? String(actionDetail.taskId).trim() : "";
|
||||
const hasMatchingAction =
|
||||
!!actionId && !!actionTaskId && actionTaskId === fallbackTaskId;
|
||||
const actionInfo = hasMatchingAction
|
||||
? {
|
||||
actionId,
|
||||
actionName: actionDetail?.actionName,
|
||||
narration: actionDetail?.narration,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
taskId: fallbackTaskId,
|
||||
agentId: agent_id,
|
||||
openingText: buildTaskOpeningText(detail, fallbackTaskId),
|
||||
openingText: buildTaskOpeningText(detail, fallbackTaskId, actionInfo),
|
||||
...(hasMatchingAction ? { actionId, partitionByAction: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return stored;
|
||||
}, [agent_id, taskBind, taskCtxOn, taskGate]);
|
||||
}, [agent_id, isXdfxTaskBind, taskBind, taskCtxOn, taskGate]);
|
||||
const taskId =
|
||||
taskCtxOn && taskContext && taskContext.agentId === agent_id
|
||||
? taskContext.taskId
|
||||
@@ -263,7 +339,7 @@ export default function AgentChatPage({
|
||||
}, [debugTemplateId, thread.messages]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
|
||||
setHasStartedConversation(true);
|
||||
// Remember this agent so the new-chat landing screen pre-selects it next
|
||||
// time (covers both direct agent chats and the ChatPage handoff replay).
|
||||
@@ -271,7 +347,7 @@ export default function AgentChatPage({
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message, { agent_id });
|
||||
void sendMessage(threadId, message, { ...(extraContext ?? {}), agent_id });
|
||||
},
|
||||
[sendMessage, threadId, agent_id],
|
||||
);
|
||||
@@ -294,9 +370,9 @@ export default function AgentChatPage({
|
||||
|
||||
// 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则
|
||||
// 仅切到对话态(隐藏面板),让用户手动输入。
|
||||
const handleGateStart = useCallback(() => {
|
||||
const handleGateStart = useCallback((gateContext?: RwfxSqGateStartContext) => {
|
||||
const text = taskContext?.openingText?.trim();
|
||||
if (text) handleSubmit({ text, files: [] });
|
||||
if (text) handleSubmit({ text, files: [] }, rwfxSqReportRunContext(gateContext));
|
||||
else setHasStartedConversation(true);
|
||||
}, [handleSubmit, taskContext]);
|
||||
|
||||
@@ -589,6 +665,7 @@ export default function AgentChatPage({
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
defaultCollapsed={taskCtxOn}
|
||||
showRecentChats={!taskCtxOn}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { QuestionNavigator } from "@/components/workspace/question-navigator";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
@@ -508,6 +509,7 @@ export default function ChatPage() {
|
||||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||||
{!compactChrome && (
|
||||
<>
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
<div
|
||||
|
||||
@@ -15,6 +15,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
@@ -40,7 +45,11 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function ChatsPage() {
|
||||
export default function ChatsPage({
|
||||
compactChrome = false,
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
}) {
|
||||
const pathOfThread = usePathOfThread();
|
||||
const { t } = useI18n();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
@@ -171,7 +180,17 @@ export default function ChatsPage() {
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader></WorkspaceHeader>
|
||||
<WorkspaceHeader
|
||||
rightActions={
|
||||
compactChrome ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full flex-col">
|
||||
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
|
||||
|
||||
@@ -26,18 +26,19 @@ import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
|
||||
import { getAccessToken } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import {
|
||||
getLastAgentId,
|
||||
isThreadRagModeLocked,
|
||||
saveLastAgentId,
|
||||
useLocalSettings,
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
@@ -50,6 +51,22 @@ import { resolveIframeAssistantId } from "./iframe-chat-agent";
|
||||
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons";
|
||||
|
||||
type BrainyChatStatus = "started" | "finished";
|
||||
type ResolvedIframeModeButton =
|
||||
| {
|
||||
key: "agent";
|
||||
label: string;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
| {
|
||||
key: "writing" | "memory" | "references" | "contextCompression" | "pro";
|
||||
label: string;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
icon: typeof FileTextIcon;
|
||||
onClick: () => void;
|
||||
activeClassName: string;
|
||||
};
|
||||
type BrainyAuthTokenMessage = {
|
||||
source: "brainy-iframe";
|
||||
type: "brainy:auth-token";
|
||||
@@ -66,6 +83,20 @@ type BrainyChatInterruptMessage = {
|
||||
};
|
||||
requestId?: string;
|
||||
};
|
||||
type BrainyUrlContextMessage = {
|
||||
source?: string;
|
||||
type?: string;
|
||||
payload?: {
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
type PendingIframeSubmit = {
|
||||
message: PromptInputMessage;
|
||||
externalContext?: Record<string, unknown>;
|
||||
options?: { additionalKwargs?: Record<string, unknown> };
|
||||
targetSelectedAgentId: string | null;
|
||||
targetForceDefaultAssistant: boolean;
|
||||
};
|
||||
|
||||
function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
|
||||
window.parent?.postMessage(
|
||||
@@ -104,9 +135,22 @@ export default function IframeChatPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [manualSelectedAgentId, setManualSelectedAgentId] = useState<string | null>(null);
|
||||
const [forceDefaultAssistant, setForceDefaultAssistant] = useState(true);
|
||||
const [pendingIframeSubmit, setPendingIframeSubmit] =
|
||||
useState<PendingIframeSubmit | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
const { data: pluginManagementSettings } = usePluginManagementSettings();
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
const handleSubmitRef = useRef<
|
||||
| ((
|
||||
message: PromptInputMessage,
|
||||
externalContext?: Record<string, unknown>,
|
||||
options?: { additionalKwargs?: Record<string, unknown> },
|
||||
) => void)
|
||||
| null
|
||||
>(null);
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
@@ -139,23 +183,37 @@ export default function IframeChatPage() {
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setSelectedAgentId(getLastAgentId());
|
||||
setSelectedAgentId(null);
|
||||
setManualSelectedAgentId(null);
|
||||
setForceDefaultAssistant(true);
|
||||
setPendingIframeSubmit(null);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectorLoading || !selectedAgentId) {
|
||||
if (selectorLoading) {
|
||||
return;
|
||||
}
|
||||
if (!selectorAgents.some((agent) => agent.id === selectedAgentId)) {
|
||||
if (
|
||||
selectedAgentId &&
|
||||
!selectorAgents.some((agent) => agent.id === selectedAgentId)
|
||||
) {
|
||||
setSelectedAgentId(null);
|
||||
setForceDefaultAssistant(true);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, selectorLoading]);
|
||||
if (
|
||||
manualSelectedAgentId &&
|
||||
!selectorAgents.some((agent) => agent.id === manualSelectedAgentId)
|
||||
) {
|
||||
setManualSelectedAgentId(null);
|
||||
}
|
||||
}, [manualSelectedAgentId, selectedAgentId, selectorAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const activeAssistantId = resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId: null,
|
||||
preferDefaultAssistant: forceDefaultAssistant,
|
||||
});
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
@@ -209,14 +267,18 @@ export default function IframeChatPage() {
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedAgentId = resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId,
|
||||
});
|
||||
if (resolvedAgentId && resolvedAgentId !== selectedAgentId) {
|
||||
setSelectedAgentId(resolvedAgentId);
|
||||
}
|
||||
}, [selectedAgentId, threadMetadataAgentId]);
|
||||
if (!threadMetadataAgentId || isNewThread) return;
|
||||
if (manualSelectedAgentId) return;
|
||||
if (forceDefaultAssistant) return;
|
||||
if (selectedAgentId) return;
|
||||
setSelectedAgentId(threadMetadataAgentId);
|
||||
}, [
|
||||
forceDefaultAssistant,
|
||||
isNewThread,
|
||||
manualSelectedAgentId,
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId,
|
||||
]);
|
||||
|
||||
const writingArtifactPath = useMemo(() => {
|
||||
const isMarkdown = (path: string | null | undefined) =>
|
||||
@@ -277,10 +339,43 @@ export default function IframeChatPage() {
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||||
setManualSelectedAgentId(agentId);
|
||||
setSelectedAgentId(agentId);
|
||||
setForceDefaultAssistant(!agentId);
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingIframeSubmit) return;
|
||||
const currentAssistantId = resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId: null,
|
||||
preferDefaultAssistant: forceDefaultAssistant,
|
||||
});
|
||||
const targetAssistantId = resolveIframeAssistantId({
|
||||
selectedAgentId: pendingIframeSubmit.targetSelectedAgentId,
|
||||
threadMetadataAgentId: null,
|
||||
preferDefaultAssistant: pendingIframeSubmit.targetForceDefaultAssistant,
|
||||
});
|
||||
if (
|
||||
currentAssistantId !== targetAssistantId ||
|
||||
selectedAgentId !== pendingIframeSubmit.targetSelectedAgentId ||
|
||||
forceDefaultAssistant !== pendingIframeSubmit.targetForceDefaultAssistant
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleSubmitRef.current?.(
|
||||
pendingIframeSubmit.message,
|
||||
pendingIframeSubmit.externalContext,
|
||||
pendingIframeSubmit.options,
|
||||
);
|
||||
setPendingIframeSubmit(null);
|
||||
}, [forceDefaultAssistant, pendingIframeSubmit, selectedAgentId]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
@@ -341,9 +436,33 @@ export default function IframeChatPage() {
|
||||
[thread],
|
||||
);
|
||||
|
||||
const applyIncomingUrlSelection = useCallback(
|
||||
(pageUrl: string | null | undefined) => {
|
||||
const nextSelection = resolveIncomingIframeSelection({
|
||||
pageUrl,
|
||||
mappings: pluginManagementSettings?.domainAgentMappings ?? [],
|
||||
availableAgentIds: selectorLoading
|
||||
? undefined
|
||||
: selectorAgents.map((agent) => agent.id),
|
||||
manualSelectedAgentId,
|
||||
});
|
||||
setSelectedAgentId(nextSelection.selectedAgentId);
|
||||
setForceDefaultAssistant(nextSelection.forceDefaultAssistant);
|
||||
return nextSelection;
|
||||
},
|
||||
[
|
||||
manualSelectedAgentId,
|
||||
pluginManagementSettings?.domainAgentMappings,
|
||||
selectorAgents,
|
||||
selectorLoading,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (
|
||||
event: MessageEvent<BrainyChatSubmitMessage | BrainyChatInterruptMessage>,
|
||||
event: MessageEvent<
|
||||
BrainyChatSubmitMessage | BrainyChatInterruptMessage | BrainyUrlContextMessage
|
||||
>,
|
||||
) => {
|
||||
const data = event.data;
|
||||
if (data?.source !== "brainy-extension") return;
|
||||
@@ -351,12 +470,19 @@ export default function IframeChatPage() {
|
||||
void handleInterrupt(data as BrainyChatInterruptMessage);
|
||||
return;
|
||||
}
|
||||
if (data?.type === "brainy:context-url") {
|
||||
applyIncomingUrlSelection(
|
||||
(data as BrainyUrlContextMessage).payload?.url ?? null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (data?.type !== "brainy:chat-submit") return;
|
||||
const submitMessage = data as BrainyChatSubmitMessage;
|
||||
|
||||
const { submitText, files, iframeContext, additionalKwargs } =
|
||||
parseBrainyChatSubmitMessage(submitMessage);
|
||||
|
||||
if (!submitText && files.length === 0) return;
|
||||
|
||||
console.log("收到插件消息", {
|
||||
text: submitMessage.payload?.text,
|
||||
prompt: submitMessage.payload?.prompt,
|
||||
@@ -368,26 +494,30 @@ export default function IframeChatPage() {
|
||||
iframeContext,
|
||||
});
|
||||
|
||||
if (!submitText && files.length === 0) return;
|
||||
|
||||
pendingRequestIdRef.current = submitMessage.requestId ?? null;
|
||||
|
||||
handleSubmit(
|
||||
{ text: submitText, files },
|
||||
iframeContext,
|
||||
additionalKwargs ? { additionalKwargs } : undefined,
|
||||
);
|
||||
setPendingIframeSubmit({
|
||||
message: { text: submitText, files },
|
||||
externalContext: iframeContext,
|
||||
options: additionalKwargs ? { additionalKwargs } : undefined,
|
||||
targetSelectedAgentId: selectedAgentId,
|
||||
targetForceDefaultAssistant: forceDefaultAssistant,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [handleInterrupt, handleSubmit]);
|
||||
}, [
|
||||
applyIncomingUrlSelection,
|
||||
forceDefaultAssistant,
|
||||
handleInterrupt,
|
||||
selectedAgentId,
|
||||
]);
|
||||
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
const bottomBarHeightClass = "pb-24";
|
||||
const modeButtons = buildIframeModeButtons({
|
||||
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
|
||||
agentSelected: !!selectedAgentId,
|
||||
writingModeEnabled: !!settings.context.writing_mode,
|
||||
memoryInjectionEnabled: Boolean(
|
||||
@@ -397,10 +527,15 @@ export default function IframeChatPage() {
|
||||
ragModeLocked,
|
||||
contextCompressionEnabled,
|
||||
proModeEnabled,
|
||||
}).map((button) => {
|
||||
}).map((button): ResolvedIframeModeButton => {
|
||||
switch (button.key) {
|
||||
case "agent":
|
||||
return button;
|
||||
return {
|
||||
key: "agent",
|
||||
label: button.label,
|
||||
active: button.active,
|
||||
disabled: button.disabled,
|
||||
};
|
||||
case "writing":
|
||||
return {
|
||||
...button,
|
||||
@@ -437,7 +572,7 @@ export default function IframeChatPage() {
|
||||
activeClassName: "text-fuchsia-600 hover:text-fuchsia-700",
|
||||
};
|
||||
default:
|
||||
return button;
|
||||
throw new Error(`Unknown iframe mode button: ${String(button.key)}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -89,9 +89,9 @@ const emptyForm = (): FormState => ({
|
||||
});
|
||||
|
||||
const newParam = (): LightAppRouteParam => ({
|
||||
key: "",
|
||||
key: "access_token",
|
||||
paramType: "login",
|
||||
source: "accessToken",
|
||||
source: "access_token",
|
||||
customValue: "",
|
||||
themeValue: "red=red;blue=blue;dark-blue=dark-blue",
|
||||
});
|
||||
@@ -107,7 +107,7 @@ function formFromApp(app: LightApp): FormState {
|
||||
routeParams: app.routeParams.map((p) => ({
|
||||
key: p.key,
|
||||
paramType: p.paramType,
|
||||
source: p.source ?? "accessToken",
|
||||
source: p.source ?? "access_token",
|
||||
customValue: p.customValue ?? "",
|
||||
themeValue: p.themeValue ?? "",
|
||||
})),
|
||||
@@ -118,7 +118,7 @@ function getParamSourceLabel(param: LightAppRouteParam): string {
|
||||
if (param.paramType === "theme") {
|
||||
return `主题参数: ${param.themeValue || ""}`;
|
||||
}
|
||||
const source = param.source || "accessToken";
|
||||
const source = param.source || "access_token";
|
||||
const option = LOGIN_SOURCE_OPTIONS.find((o) => o.value === source);
|
||||
if (source === "other") {
|
||||
return `登录参数: ${param.customValue || ""}`;
|
||||
@@ -977,7 +977,7 @@ export default function LightAppManagementPage() {
|
||||
onChange={(v) =>
|
||||
patchParam(idx, {
|
||||
paramType: v as "login" | "theme",
|
||||
source: v === "login" ? (p.source || "accessToken") : undefined,
|
||||
source: v === "login" ? (p.source || "access_token") : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -988,7 +988,7 @@ export default function LightAppManagementPage() {
|
||||
<Select
|
||||
className="w-full"
|
||||
size="small"
|
||||
value={p.source || "accessToken"}
|
||||
value={p.source || "access_token"}
|
||||
options={LOGIN_SOURCE_OPTIONS}
|
||||
popupProps={DIALOG_SELECT_POPUP}
|
||||
onChange={(v) => patchParam(idx, { source: v as string })}
|
||||
@@ -1112,7 +1112,7 @@ function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
|
||||
.map((p) => {
|
||||
const base: LightAppRouteParam = { key: p.key.trim(), paramType: p.paramType };
|
||||
if (p.paramType === "login") {
|
||||
base.source = p.source || "accessToken";
|
||||
base.source = p.source || "access_token";
|
||||
if (base.source === "other") {
|
||||
base.customValue = (p.customValue ?? "").trim();
|
||||
}
|
||||
|
||||
@@ -35,6 +35,35 @@ import { fetchYLogConfig } from '@/core/log/y-log-config';
|
||||
import { persistYLogToken } from '@/core/log/y-log-token';
|
||||
import { persistLoginCredentials } from '@/core/auth/login-credentials';
|
||||
import { OptType, writeYLog } from '@/core/log/y-log';
|
||||
import { authStorageGet } from '@/core/auth/scoped-storage';
|
||||
|
||||
/** 令牌脱敏:日志里只露头尾,避免完整令牌泄漏。 */
|
||||
function maskLoginToken(value: string): string {
|
||||
if (!value) return '';
|
||||
if (value.length <= 10) return `${value.slice(0, 2)}***(${value.length})`;
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}(${value.length})`;
|
||||
}
|
||||
|
||||
/** 读取已落地的 userInfo(脱敏 access token 后)供登录后排查。 */
|
||||
function summarizeStoredUserInfo(): Record<string, unknown> | null {
|
||||
try {
|
||||
const raw = authStorageGet('userInfo');
|
||||
if (!raw) return null;
|
||||
const info = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
keys: Object.keys(info),
|
||||
yUserId: info.yUserId,
|
||||
userId: info.userId,
|
||||
userName: info.userName,
|
||||
gateDepartName: info.gateDepartName,
|
||||
typeName: info.typeName,
|
||||
access_token: maskLoginToken(String(info.access_token ?? '')),
|
||||
accessToken: maskLoginToken(String(info.accessToken ?? '')),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
const IFRAME_LOGIN_SOURCE = 'iframe';
|
||||
@@ -75,7 +104,7 @@ export default function LoginPage() {
|
||||
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
||||
const accessToken = searchParams.get('access_token')?.trim() ?? '';
|
||||
const access_token = searchParams.get('access_token')?.trim() ?? '';
|
||||
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
|
||||
const gatePassword = searchParams.get('password') ?? '';
|
||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||
@@ -171,19 +200,68 @@ export default function LoginPage() {
|
||||
async function runLogin() {
|
||||
setStatus('loading');
|
||||
setErrorMessage('');
|
||||
|
||||
// ── 调试:登录路由参数全打印 ──────────────────────────────────────────────
|
||||
// token / yUserId / 用户名 / ticket 任一方式都打印(令牌脱敏),方便排查
|
||||
// “userInfo 取不到值 / 传值不对” 一类问题。`embed=0`/正常态也照打。
|
||||
const loginMethod = authToken
|
||||
? 'token(authToken)'
|
||||
: yUserId
|
||||
? 'yUserId'
|
||||
: onlineTicket
|
||||
? 'onlineTicket'
|
||||
: toolToken
|
||||
? 'toolsToken'
|
||||
: 'username';
|
||||
console.info('[login] route params', {
|
||||
method: loginMethod,
|
||||
pathUsername: resolvedUsername,
|
||||
yUserId,
|
||||
access_token: maskLoginToken(access_token),
|
||||
toolToken: maskLoginToken(toolToken),
|
||||
authToken: maskLoginToken(authToken),
|
||||
gatePassword: gatePassword ? maskLoginToken(gatePassword) : '',
|
||||
onlineTicket: onlineTicket ? maskLoginToken(onlineTicket) : '',
|
||||
taskId,
|
||||
goPath,
|
||||
redirect: redirectTo,
|
||||
isEmbed,
|
||||
rawSearch: searchParams.toString(),
|
||||
});
|
||||
|
||||
try {
|
||||
let auth;
|
||||
let loginUserName = '';
|
||||
|
||||
// 外部用户信息(yUserId → consumer 用户详情)与登录鉴权方式**解耦**:只要 URL
|
||||
// 带 yUserId 就拉一次 userDetailByYuserId,把 yUserId/userName/gateDepartName/
|
||||
// typeName/accessToken 落进 userInfo。之前 token(authToken) 登录会短路掉这一步,
|
||||
// 导致 token 登录后 userInfo 残缺(只剩 userName)、iframe/轻应用拿不到这些值。
|
||||
if (yUserId) {
|
||||
if (authToken) {
|
||||
// token 登录下 consumer 失败不应阻断登录(仅影响 userInfo 的富信息)。
|
||||
try {
|
||||
await loginByYUserId(yUserId, toolToken || null, access_token || null);
|
||||
} catch (error) {
|
||||
console.warn('[login] loginByYUserId failed under token login (non-fatal)', error);
|
||||
}
|
||||
} else {
|
||||
await loginByYUserId(yUserId, toolToken || null, access_token || null);
|
||||
}
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
|
||||
auth = await loginByToken(authToken);
|
||||
// 让 token 登录也写入正确用户名(后端 getTokenInfo 解析出的原始登录名)。
|
||||
// 若上面 yUserId 已从 consumer 落了 userName,则 setStoredUserName 不会覆盖它。
|
||||
loginUserName = auth.username?.trim() ?? '';
|
||||
} else {
|
||||
let deerflowUsername = resolvedUsername;
|
||||
if (onlineTicket) {
|
||||
// yUserId 优先(已在上面落好 userInfo + tools_token);其余方式仅在无 yUserId 时启用。
|
||||
if (!yUserId && onlineTicket) {
|
||||
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
|
||||
} else if (yUserId) {
|
||||
await loginByYUserId(yUserId, toolToken || null);
|
||||
} else if (toolToken) {
|
||||
} else if (!yUserId && toolToken) {
|
||||
persistToolsToken(toolToken);
|
||||
}
|
||||
|
||||
@@ -212,9 +290,22 @@ export default function LoginPage() {
|
||||
}
|
||||
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
|
||||
setStoredUserName(loginUserName);
|
||||
// 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
|
||||
// XK/PG 中任务 iframe 据此取 accessToken
|
||||
persistAccessToken(accessToken);
|
||||
// 非 yUserId 登录仍可直接持久化 access_token;yUserId 登录已在 loginByYUserId 内按
|
||||
// “接口 accessToken 优先,URL access_token 仅兜底”的规则处理。
|
||||
if (!yUserId) {
|
||||
persistAccessToken(access_token);
|
||||
}
|
||||
|
||||
// ── 调试:登录落地结果(auth + 实际写入的 userInfo,access token 已脱敏) ──
|
||||
console.info('[login] stored result', {
|
||||
method: loginMethod,
|
||||
user_id: auth.user_id,
|
||||
email: auth.email,
|
||||
authUsername: auth.username,
|
||||
loginUserName,
|
||||
userInfo: summarizeStoredUserInfo(),
|
||||
});
|
||||
|
||||
// 嵌入模式不写 sidebarEnabled(localStorage 的「简洁/全量」偏好会跨标签污染正常页);
|
||||
// 去 chrome 由按标签页的嵌入会话(isEmbedActive)驱动,见 PageLayout。
|
||||
if (!isEmbed) persistSidebarEnabled(sidebarEnabled);
|
||||
@@ -273,7 +364,7 @@ export default function LoginPage() {
|
||||
sidebarEnabled,
|
||||
yUserId,
|
||||
toolToken,
|
||||
accessToken,
|
||||
access_token,
|
||||
onlineTicket,
|
||||
authToken,
|
||||
gatePassword,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
useModelConcurrencySettings,
|
||||
useUpdateModelConcurrencySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export default function ModelConcurrencySettingsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { data, isLoading, error } = useModelConcurrencySettings();
|
||||
const { models } = useModels();
|
||||
const update = useUpdateModelConcurrencySettings();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [defaultLimit, setDefaultLimit] = useState(10);
|
||||
const [modelLimits, setModelLimits] = useState<Record<string, string>>({});
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setEnabled(data.enabled);
|
||||
setDefaultLimit(data.default_limit);
|
||||
setModelLimits(
|
||||
Object.fromEntries(
|
||||
Object.entries(data.model_limits ?? {}).map(([name, limit]) => [
|
||||
name,
|
||||
String(limit),
|
||||
]),
|
||||
),
|
||||
);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
const modelNames = useMemo(() => models.map((model) => model.name), [models]);
|
||||
|
||||
const handleModelLimitChange = (modelName: string, value: string) => {
|
||||
setModelLimits((prev) => ({ ...prev, [modelName]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const nextDefault = Math.max(1, Math.min(Math.round(defaultLimit) || 10, 10000));
|
||||
const cleaned: Record<string, number> = {};
|
||||
for (const [name, rawValue] of Object.entries(modelLimits)) {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) continue;
|
||||
const parsed = Math.round(Number(trimmed));
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
toast.error(`模型 ${name} 的并发上限必须是大于 0 的整数`);
|
||||
return;
|
||||
}
|
||||
cleaned[name] = Math.min(parsed, 10000);
|
||||
}
|
||||
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
enabled,
|
||||
default_limit: nextDefault,
|
||||
model_limits: cleaned,
|
||||
});
|
||||
setDefaultLimit(nextDefault);
|
||||
setModelLimits(
|
||||
Object.fromEntries(
|
||||
Object.entries(cleaned).map(([name, limit]) => [name, String(limit)]),
|
||||
),
|
||||
);
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? "flex w-full flex-col gap-6"
|
||||
: "mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 overflow-y-auto px-6 py-10"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
|
||||
模型并发限制
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
按模型限制同时进行中的问答数。达到上限后,新请求会收到“当前使用人数较多,请稍等一下再试”的提示。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">开启限制</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
关闭后,不再按模型并发数拦截问答请求。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">默认上限</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
未单独配置的模型,使用这个并发上限。
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={defaultLimit}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setDefaultLimit(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">按模型单独设置</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
留空表示使用默认上限。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{modelNames.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-6 text-sm">
|
||||
当前没有可配置的模型。
|
||||
</div>
|
||||
) : (
|
||||
modelNames.map((modelName) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="flex items-center justify-between gap-4 rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{modelName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
留空时使用默认上限 {defaultLimit || 10}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={modelLimits[modelName] ?? ""}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => handleModelLimitChange(modelName, e.target.value)}
|
||||
className="w-32"
|
||||
placeholder={String(defaultLimit || 10)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import {
|
||||
type DomainAgentMapping,
|
||||
normalizeDomainInput,
|
||||
} from "@/core/plugin-management/domain-agent";
|
||||
import type { PluginManagementSettings } from "@/core/system-settings/api";
|
||||
import {
|
||||
usePluginManagementSettings,
|
||||
useUpdatePluginManagementSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
function makeRow(): DomainAgentMapping {
|
||||
return {
|
||||
id:
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
domain: "",
|
||||
agentId: "",
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRows(
|
||||
rows: DomainAgentMapping[],
|
||||
): PluginManagementSettings["domainAgentMappings"] {
|
||||
const seen = new Set<string>();
|
||||
const next: PluginManagementSettings["domainAgentMappings"] = [];
|
||||
for (const row of rows) {
|
||||
const domain = normalizeDomainInput(row.domain);
|
||||
const agentId = row.agentId.trim();
|
||||
if (!domain || !agentId) continue;
|
||||
if (seen.has(domain)) continue;
|
||||
seen.add(domain);
|
||||
next.push({
|
||||
id: row.id || makeRow().id,
|
||||
domain,
|
||||
agentId,
|
||||
enabled: row.enabled,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function PluginManagementPage() {
|
||||
const { data, isLoading, error } = usePluginManagementSettings();
|
||||
const update = useUpdatePluginManagementSettings();
|
||||
const { agents, isLoading: agentsLoading } = useAgentSelector();
|
||||
const [rows, setRows] = useState<DomainAgentMapping[]>([]);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setRows(
|
||||
data.domainAgentMappings.length > 0
|
||||
? data.domainAgentMappings
|
||||
: [makeRow()],
|
||||
);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
const agentOptions = useMemo(
|
||||
() =>
|
||||
[...agents]
|
||||
.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id, "zh-CN"))
|
||||
.map((agent) => ({
|
||||
id: agent.id,
|
||||
label: agent.name || agent.id,
|
||||
})),
|
||||
[agents],
|
||||
);
|
||||
|
||||
const updateRow = (id: string, patch: Partial<DomainAgentMapping>) => {
|
||||
setRows((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeRow = (id: string) => {
|
||||
setRows((current) => {
|
||||
const next = current.filter((row) => row.id !== id);
|
||||
return next.length > 0 ? next : [makeRow()];
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const domainAgentMappings = normalizeRows(rows);
|
||||
try {
|
||||
const saved = await update.mutateAsync({ domainAgentMappings });
|
||||
setRows(
|
||||
saved.domainAgentMappings.length > 0
|
||||
? saved.domainAgentMappings
|
||||
: [makeRow()],
|
||||
);
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-10">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">插件管理</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理插件传入页面地址的域名与默认智能体映射。命中后,插件ZNCM模式会按配置默认选择对应智能体。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">域名和智能体配置</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
输入域名或完整 URL,保存时会自动规范化为 host。只按域名精确匹配。
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRows((current) => [...current, makeRow()])}
|
||||
>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
新增配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_120px_72px] gap-3 text-sm font-medium">
|
||||
<div>域名</div>
|
||||
<div>智能体</div>
|
||||
<div>启用</div>
|
||||
<div>操作</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_120px_72px] gap-3"
|
||||
>
|
||||
<Input
|
||||
value={row.domain}
|
||||
onChange={(e) => updateRow(row.id, { domain: e.target.value })}
|
||||
placeholder="如:https://example.com/path 或 example.com"
|
||||
/>
|
||||
<select
|
||||
value={row.agentId}
|
||||
onChange={(e) => updateRow(row.id, { agentId: e.target.value })}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={agentsLoading}
|
||||
>
|
||||
<option value="">请选择智能体</option>
|
||||
{agentOptions.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
checked={row.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRow(row.id, { enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-9 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label="删除配置"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,11 @@ import {
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
@@ -144,7 +149,11 @@ const SCHEDULED_TASK_TEXTAREA_CLASS =
|
||||
const DESCRIPTION_TEXTAREA_ROWS = 4;
|
||||
const PROMPT_TEXTAREA_ROWS = 10;
|
||||
|
||||
export default function ScheduledTasksPage() {
|
||||
export default function ScheduledTasksPage({
|
||||
compactChrome = false,
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useTheme();
|
||||
const darkToastOpts =
|
||||
@@ -490,7 +499,17 @@ export default function ScheduledTasksPage() {
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceHeader
|
||||
rightActions={
|
||||
compactChrome ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<WorkspaceBody>
|
||||
<div className="grid size-full min-h-0 grid-cols-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 p-4 xl:grid-cols-[420px_minmax(0,1fr)] xl:grid-rows-1">
|
||||
<Card className="flex min-h-0 min-w-0 flex-col">
|
||||
|
||||
@@ -51,6 +51,7 @@ import MemoryPage from "./MemoryPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
@@ -346,6 +347,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/model-concurrency"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<ModelConcurrencySettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function TaskButtonsPage() {
|
||||
);
|
||||
|
||||
const addLoginParam = (id: string) =>
|
||||
updateLoginParams(id, (ps) => [...ps, { key: "", source: "accessToken", customValue: "" }]);
|
||||
updateLoginParams(id, (ps) => [...ps, { key: "access_token", source: "access_token", customValue: "" }]);
|
||||
|
||||
const patchLoginParam = (id: string, idx: number, patch: Partial<TaskButtonLoginParam>) =>
|
||||
updateLoginParams(id, (ps) => ps.map((p, i) => (i === idx ? { ...p, ...patch } : p)));
|
||||
|
||||
@@ -5,7 +5,13 @@ import { getStoredAuth } from '@/core/auth';
|
||||
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from '@/components/workspace/appearance-controls';
|
||||
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
|
||||
import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
|
||||
import { CommandPalette } from '@/components/workspace/command-palette';
|
||||
import { Toaster } from 'sonner';
|
||||
import { SubtasksProvider } from '@/core/tasks/context';
|
||||
@@ -31,6 +37,7 @@ import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
|
||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
@@ -41,6 +48,7 @@ import AdminMemoryPage from './AdminMemoryPage';
|
||||
import AdminUserHubPage from './AdminUserHubPage';
|
||||
import TagsPage from './TagsPage';
|
||||
import PositionsPage from './PositionsPage';
|
||||
import PluginManagementPage from './PluginManagementPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
@@ -71,6 +79,38 @@ function isInIframe(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
return (
|
||||
pathname === '/page/workspace/agents' ||
|
||||
pathname === '/page/workspace/skills' ||
|
||||
pathname === '/page/workspace/memory' ||
|
||||
pathname === '/page/workspace/knowledge' ||
|
||||
pathname.startsWith('/page/workspace/knowledge/')
|
||||
);
|
||||
}
|
||||
|
||||
function CompactWorkspaceTopActions() {
|
||||
return (
|
||||
<div className="z-[60] flex h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 bg-card/90 px-4 shadow-sm backdrop-blur-md">
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
<div className="bg-border/70 mx-1 h-6 w-px shrink-0" aria-hidden />
|
||||
<UserIdentityBadge />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactAppearanceActions() {
|
||||
return (
|
||||
<>
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceLayout({
|
||||
children,
|
||||
pageSidebarCollapsed,
|
||||
@@ -97,7 +137,10 @@ export function WorkspaceLayout({
|
||||
}) {
|
||||
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
||||
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
||||
const isGeneralQa = useLocation().pathname.startsWith("/page/workspace/chats");
|
||||
const pathname = useLocation().pathname;
|
||||
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
|
||||
const showCompactTopActions =
|
||||
headerHidden && shouldShowCompactTopActions(pathname);
|
||||
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? '0rem'
|
||||
@@ -135,7 +178,14 @@ export function WorkspaceLayout({
|
||||
<SidebarInset
|
||||
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
|
||||
>
|
||||
{children}
|
||||
{showCompactTopActions ? (
|
||||
<>
|
||||
<CompactWorkspaceTopActions />
|
||||
<div className="min-h-0 flex-1">{children}</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
@@ -221,7 +271,7 @@ export default function WorkspaceRoutes({
|
||||
path="chats"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatsPage />
|
||||
<ChatsPage compactChrome={headerHidden} />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -304,7 +354,12 @@ export default function WorkspaceRoutes({
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<AgentChatPage showAgentList />
|
||||
<AgentChatPage
|
||||
showAgentList
|
||||
compactAppearanceActions={
|
||||
headerHidden ? <CompactAppearanceActions /> : undefined
|
||||
}
|
||||
/>
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -337,7 +392,10 @@ export default function WorkspaceRoutes({
|
||||
path="ai-writing/*"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AIWritingPage hideAdvancedFields />
|
||||
<AIWritingPage
|
||||
hideAdvancedFields
|
||||
compactChrome={headerHidden}
|
||||
/>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -370,7 +428,7 @@ export default function WorkspaceRoutes({
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
<RoundtablePlanningPage compactChrome={headerHidden} />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -432,7 +490,7 @@ export default function WorkspaceRoutes({
|
||||
path="scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTasksPage />
|
||||
<ScheduledTasksPage compactChrome={headerHidden} />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -514,6 +572,18 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/plugins"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PluginManagementPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/positions"
|
||||
element={
|
||||
@@ -562,6 +632,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/model-concurrency"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ModelConcurrencySettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/publish-squares"
|
||||
element={
|
||||
|
||||
@@ -33,3 +33,14 @@ test("falls back to lead-agent mode when no agent is selected or bound", () => {
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("can force lead-agent mode even when the thread metadata is bound to another agent", () => {
|
||||
assert.equal(
|
||||
resolveIframeAssistantId({
|
||||
selectedAgentId: null,
|
||||
threadMetadataAgentId: "roundtable-structure",
|
||||
preferDefaultAssistant: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
type ResolveIframeAssistantIdInput = {
|
||||
selectedAgentId: string | null;
|
||||
threadMetadataAgentId: string | null;
|
||||
preferDefaultAssistant?: boolean;
|
||||
};
|
||||
|
||||
function normalizeAgentId(agentId: string | null | undefined): string | undefined {
|
||||
@@ -11,7 +12,11 @@ function normalizeAgentId(agentId: string | null | undefined): string | undefine
|
||||
export function resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId,
|
||||
preferDefaultAssistant = false,
|
||||
}: ResolveIframeAssistantIdInput): string | undefined {
|
||||
if (preferDefaultAssistant) {
|
||||
return normalizeAgentId(selectedAgentId);
|
||||
}
|
||||
return (
|
||||
normalizeAgentId(selectedAgentId) ??
|
||||
normalizeAgentId(threadMetadataAgentId)
|
||||
|
||||
@@ -61,6 +61,10 @@ import {
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -282,7 +286,11 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
|
||||
}
|
||||
|
||||
|
||||
export default function RoundtablePlanningPage() {
|
||||
export default function RoundtablePlanningPage({
|
||||
compactChrome = false,
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||
const { watchJob } = useRoundtableJobNotifier();
|
||||
@@ -2075,6 +2083,12 @@ export default function RoundtablePlanningPage() {
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
{compactChrome && (
|
||||
<>
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2715,7 +2729,8 @@ function LeftSidebar({
|
||||
ref={dzRef}
|
||||
id="left-sidebar"
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out",
|
||||
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
|
||||
isStep2 ? "overflow-y-auto" : "overflow-hidden",
|
||||
isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]",
|
||||
)}
|
||||
>
|
||||
@@ -2897,6 +2912,7 @@ function RoundtableEntryRail({
|
||||
businessCode?: string | null;
|
||||
}) {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
|
||||
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
|
||||
const { drafts: records, refresh } = useRoundtableDrafts(taskId);
|
||||
// 分析记录「按业务链筛选」—— 选项从已加载草稿的 chainTitle 去重得到,
|
||||
@@ -2947,7 +2963,7 @@ function RoundtableEntryRail({
|
||||
let cancelled = false;
|
||||
void listChains("public")
|
||||
.then((all) => {
|
||||
if (!cancelled) setChains(all.slice(0, 3));
|
||||
if (!cancelled) setChains(all);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 业务链非关键,静默忽略 */
|
||||
@@ -2971,7 +2987,7 @@ function RoundtableEntryRail({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
|
||||
{deeplink ? (
|
||||
isXdfx ? (
|
||||
@@ -3014,33 +3030,60 @@ function RoundtableEntryRail({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* 业务链(公共,前 3 个) */
|
||||
/* 业务链(公共列表,内部滚动) */
|
||||
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] transition-shadow duration-300 hover:shadow-[0_4px_24px_rgba(45,37,34,0.04)]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
|
||||
<Layers className="w-3 h-3" strokeWidth={2.5} />
|
||||
</span>
|
||||
<h2 className="text-sm font-semibold tracking-wide text-slate-900">业务链</h2>
|
||||
<span className="ml-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-100 text-sm font-bold tabular-nums leading-none text-neutral-500">
|
||||
{chains.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChainSectionExpanded((v) => !v)}
|
||||
title={chainSectionExpanded ? "收起业务链" : "展开业务链"}
|
||||
aria-expanded={chainSectionExpanded}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[#E6C9C4] bg-white text-neutral-400 transition-colors hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform duration-300 ease-out",
|
||||
chainSectionExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{chains.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{chains.map((chain) => {
|
||||
const selected = selectedChainId === chain.id;
|
||||
const graphOpen = expandedChainId === chain.id;
|
||||
const stageGroups = getChainStageGroups(chain);
|
||||
let seatColorIndex = 0;
|
||||
return (
|
||||
<div
|
||||
key={chain.id}
|
||||
className={cn(
|
||||
"rounded-xl border transition-all duration-200",
|
||||
selected
|
||||
? "border-primary/25 bg-primary/5 ring-1 ring-primary/20 shadow-sm"
|
||||
: "border-[#ECE7E4] bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="grid overflow-hidden transition-all duration-300 ease-out"
|
||||
style={{
|
||||
gridTemplateRows: chainSectionExpanded ? "1fr" : "0fr",
|
||||
marginTop: chainSectionExpanded ? 12 : 0,
|
||||
opacity: chainSectionExpanded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
{chains.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
|
||||
) : (
|
||||
<div className="max-h-[292px] overflow-y-auto pr-1 -mr-1 custom-scrollbar">
|
||||
<div className="flex flex-col gap-2.5 py-0.5">
|
||||
{chains.map((chain) => {
|
||||
const selected = selectedChainId === chain.id;
|
||||
const graphOpen = expandedChainId === chain.id;
|
||||
const stageGroups = getChainStageGroups(chain);
|
||||
let seatColorIndex = 0;
|
||||
return (
|
||||
<div
|
||||
key={chain.id}
|
||||
className={cn(
|
||||
"min-h-[88px] rounded-xl border transition-all duration-200",
|
||||
selected
|
||||
? "border-primary/25 bg-primary/5 ring-1 ring-primary/20 shadow-sm"
|
||||
: "border-[#ECE7E4] bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectChain(chain)}
|
||||
@@ -3189,11 +3232,14 @@ function RoundtableEntryRail({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3328,7 +3374,7 @@ function RoundtableEntryRail({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,21 +8,21 @@ import {
|
||||
|
||||
import { RouteBaseContext } from "@/core/route-base";
|
||||
|
||||
export function useRouter() {
|
||||
const navigate = useNavigate();
|
||||
const base = useContext(RouteBaseContext);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
push: (href: string) =>
|
||||
navigate(href.replace("/page/workspace", base)),
|
||||
replace: (href: string) =>
|
||||
navigate(href.replace("/page/workspace", base), { replace: true }),
|
||||
back: () => navigate(-1),
|
||||
forward: () => navigate(1),
|
||||
refresh: () => window.location.reload(),
|
||||
prefetch: async () => {},
|
||||
}),
|
||||
export function useRouter() {
|
||||
const navigate = useNavigate();
|
||||
const base = useContext(RouteBaseContext);
|
||||
const resolveHref = (href: string) =>
|
||||
href.startsWith(base) ? href : href.replace("/page/workspace", base);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
push: (href: string) => navigate(resolveHref(href)),
|
||||
replace: (href: string) => navigate(resolveHref(href), { replace: true }),
|
||||
back: () => navigate(-1),
|
||||
forward: () => navigate(1),
|
||||
refresh: () => window.location.reload(),
|
||||
prefetch: async () => {},
|
||||
}),
|
||||
[navigate, base],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "@/components/page-sdebar.css";
|
||||
import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls";
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { getStoredAuth, getAccountDisplayName } from "@/core/auth";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
@@ -124,6 +128,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
|
||||
const actionCluster = (
|
||||
<>
|
||||
<CompactShortcutButtons tone={isDark ? "banner-dark" : "banner-light"} />
|
||||
<ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} />
|
||||
|
||||
<LayoutModeSwitcher
|
||||
|
||||
@@ -15,6 +15,7 @@ import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql';
|
||||
import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
|
||||
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { copyTextToClipboard } from '@/core/clipboard';
|
||||
|
||||
export interface WikiLinkTarget {
|
||||
target: string;
|
||||
@@ -101,11 +102,11 @@ const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = (
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codeText);
|
||||
await copyTextToClipboard(codeText);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch (error) {
|
||||
console.error('复制代码失败:', error);
|
||||
} catch {
|
||||
// The shared clipboard helper already tries the browser fallback.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ interface ServerButton {
|
||||
function toLoginParam(p: ServerLoginParam): TaskButtonLoginParam {
|
||||
return {
|
||||
key: p.key ?? "",
|
||||
source: p.source || "accessToken",
|
||||
source: p.source || "access_token",
|
||||
customValue: p.custom_value ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
platformTypeChange,
|
||||
} from './GeneralQA.mock';
|
||||
import { requestCrudService } from '@/service/request-axios-service';
|
||||
import { authStorageGet } from '@/core/auth/scoped-storage';
|
||||
import { copyTextToClipboard } from '@/core/clipboard';
|
||||
|
||||
/**
|
||||
* 通用问答组件
|
||||
@@ -138,7 +140,7 @@ const GeneralQA: React.FC<{
|
||||
const mockSingleRoundStoppedRef = useRef(false);
|
||||
const mockMultiRoundStoppedRef = useRef(false);
|
||||
const chatListRef = useRef<HTMLDivElement | null>(null);
|
||||
const userId = useRef(localStorage.getItem('userId') || '387'); // 用户id
|
||||
const userId = useRef(authStorageGet('userId') || '387'); // 用户id
|
||||
const qaType = useRef('GENERAL'); // 问答类型
|
||||
const chatDataId = useRef(); // 聊天窗口id
|
||||
const compareChatId = useRef(); // 对比对话记录id
|
||||
@@ -1762,9 +1764,9 @@ const GeneralQA: React.FC<{
|
||||
const handleCopyAssistantMessage = async (message: Message) => {
|
||||
const answerText = getAssistantAnswerText(message);
|
||||
try {
|
||||
await navigator.clipboard.writeText(answerText);
|
||||
} catch (copyError) {
|
||||
console.error('复制模型回答失败:', copyError);
|
||||
await copyTextToClipboard(answerText);
|
||||
} catch {
|
||||
// Keep the chat surface quiet when the browser blocks clipboard access.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import {
|
||||
getKnowledgeIframeSkin,
|
||||
} from "@/core/strategy/external-links";
|
||||
import { authStorageGet } from "@/core/auth/scoped-storage";
|
||||
import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import ExternalIframePage from "../common/ExternalIframePage";
|
||||
|
||||
@@ -24,7 +25,7 @@ const KnowledgeBaseResourceManagement: React.FC = () => {
|
||||
|
||||
try {
|
||||
const userInfo = JSON.parse(jsonStr);
|
||||
const token = userInfo.accessToken;
|
||||
const token = normalizeExternalAccessToken(userInfo?.accessToken);
|
||||
if (!token) return;
|
||||
|
||||
const res = (await service.post(apiUrl, {
|
||||
|
||||
+13
-2
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/core/auth/task-purpose";
|
||||
import {
|
||||
fetchActionDetail,
|
||||
readActionDetail,
|
||||
readCopTaskDetail,
|
||||
type ActionDetail,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
@@ -61,12 +62,22 @@ function useChatsBase(taskBase: string): {
|
||||
const agentId = agent_id ?? "";
|
||||
const ctx = readTaskContext();
|
||||
const detail = readCopTaskDetail();
|
||||
const isXdfx = taskBase.includes("/plan-task-action");
|
||||
const cachedAction = isXdfx ? readActionDetail() : null;
|
||||
const fallbackTaskId =
|
||||
detail?.id == null ? "" : String(detail.id).trim();
|
||||
const fallbackActionId = cachedAction?.id ? String(cachedAction.id).trim() : "";
|
||||
const fallbackActionTaskId = cachedAction?.taskId ? String(cachedAction.taskId).trim() : "";
|
||||
const hasMatchingAction =
|
||||
!!fallbackActionId && !!fallbackActionTaskId && fallbackActionTaskId === fallbackTaskId;
|
||||
// 3qfx 偶发会出现 task context 丢失,但任务详情仍在 sessionStorage 的情况。
|
||||
// 这里回退到任务详情里的 taskId,避免左侧对话列表直接失效成“空任务态”。
|
||||
const taskId = ctx?.taskId ?? fallbackTaskId;
|
||||
const actionId = ctx?.partitionByAction ? (ctx.actionId ?? "") : "";
|
||||
const taskId = ctx?.taskId ?? (hasMatchingAction ? fallbackActionTaskId : fallbackTaskId);
|
||||
const actionId = ctx?.partitionByAction
|
||||
? (ctx.actionId ?? "")
|
||||
: hasMatchingAction
|
||||
? fallbackActionId
|
||||
: "";
|
||||
return { taskId, agentId, actionId, chatsBase: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -31,7 +31,10 @@ import {
|
||||
type TaskPurposeResult,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink";
|
||||
import { useRwfxSqGate } from "@/strategy-components/components/task-components/rwfx-sq-gate";
|
||||
import {
|
||||
useRwfxSqGate,
|
||||
type RwfxSqGateStartContext,
|
||||
} from "@/strategy-components/components/task-components/rwfx-sq-gate";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -164,7 +167,7 @@ export function TaskPurposePanel({
|
||||
}: {
|
||||
taskId: string;
|
||||
/** 闸门通过(无数据 / 覆盖成功),开始问答。 */
|
||||
onStart: () => void;
|
||||
onStart: (context?: RwfxSqGateStartContext) => void;
|
||||
/** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */
|
||||
autoStartWhenEmpty?: boolean;
|
||||
}) {
|
||||
@@ -209,7 +212,7 @@ export function TaskPurposePanel({
|
||||
|
||||
const handleStart = () => {
|
||||
if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖
|
||||
else onStart(); // 无数据 → 直接开始
|
||||
else gateStart(onStart); // 无数据 → 经过 3q 门控后开始
|
||||
};
|
||||
|
||||
const handleConfirmOverwrite = async () => {
|
||||
@@ -218,7 +221,7 @@ export function TaskPurposePanel({
|
||||
const r = await deleteTaskPurpose(taskId);
|
||||
if (r.success) {
|
||||
setConfirmOpen(false);
|
||||
onStart();
|
||||
gateStart(onStart);
|
||||
} else {
|
||||
toast.error(r.message || "无法覆盖,请稍后再试");
|
||||
setConfirmOpen(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* rwfx(任务分析)专用「3q 预检门控」。
|
||||
*
|
||||
* 进入任务分析工作区时调研判情报报告接口(``fetchSqReportStatus``)判断 3q 是否完成:
|
||||
* 进入任务分析工作区时调研判情报报告接口(``fetchSqReportCheck``)判断 3q 是否完成:
|
||||
* - ``incomplete``(3q 未完成 / 无数据)→ 弹「3q 未完成,是否前往 3qfx」框(暂不 / 前往 3qfx)。
|
||||
* - ``complete`` / ``unknown`` → 不打扰。
|
||||
*
|
||||
@@ -37,11 +37,18 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
|
||||
import { fetchSqReportCheck, type SqReportStatus } from "@/core/auth/dashboard-input";
|
||||
import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink";
|
||||
|
||||
type GateStatus = "loading" | "incomplete" | "ok";
|
||||
|
||||
export interface RwfxSqGateStartContext {
|
||||
/** 3q 研判情报报告状态。即使未完成/未知也会随 run context 传出,便于后端判断与本地验证。 */
|
||||
sqReportStatus: SqReportStatus;
|
||||
/** 已完成的 3q 研判情报报告。只随 run context 传给模型,不在页面展示。 */
|
||||
sqReport?: unknown;
|
||||
}
|
||||
|
||||
interface RwfxSqGateValue {
|
||||
/**
|
||||
* rwfx 自动开始第一次问答的门控。
|
||||
@@ -49,7 +56,7 @@ interface RwfxSqGateValue {
|
||||
* - 3q 未完成 → 挂起 ``start``,弹「前往 3qfx」框;用户点「暂不」才 ``start()``,点「前往 3qfx」跳转并丢弃。
|
||||
* 非 rwfx / 无 Provider → 直接 ``start()``(默认值)。
|
||||
*/
|
||||
gateStart: (start: () => void) => void;
|
||||
gateStart: (start: (context?: RwfxSqGateStartContext) => void) => void;
|
||||
}
|
||||
|
||||
const RwfxSqGateContext = createContext<RwfxSqGateValue>({
|
||||
@@ -72,56 +79,70 @@ export function RwfxSqGateProvider({
|
||||
const [jumping, setJumping] = useState(false);
|
||||
|
||||
const statusRef = useRef<GateStatus>(taskId ? "loading" : "ok");
|
||||
const sqReportStatusRef = useRef<SqReportStatus | null>(taskId ? null : "unknown");
|
||||
const sqReportRef = useRef<unknown | null>(null);
|
||||
// 待触发的「开始问答」回调:3q 未完成时挂起,点「暂不」/关闭弹框后触发。
|
||||
const pendingStartRef = useRef<(() => void) | null>(null);
|
||||
const pendingStartRef = useRef<((context?: RwfxSqGateStartContext) => void) | null>(null);
|
||||
|
||||
const buildStartContext = useCallback((): RwfxSqGateStartContext | undefined => {
|
||||
const sqReportStatus = sqReportStatusRef.current;
|
||||
if (!sqReportStatus) return undefined;
|
||||
const sqReport = sqReportRef.current;
|
||||
return sqReport == null ? { sqReportStatus } : { sqReportStatus, sqReport };
|
||||
}, []);
|
||||
|
||||
// taskId 变化:重置门控状态并预检 3q。
|
||||
useEffect(() => {
|
||||
pendingStartRef.current = null;
|
||||
sqReportStatusRef.current = null;
|
||||
sqReportRef.current = null;
|
||||
setOpen(false);
|
||||
if (!taskId) {
|
||||
statusRef.current = "ok";
|
||||
sqReportStatusRef.current = "unknown";
|
||||
return;
|
||||
}
|
||||
statusRef.current = "loading";
|
||||
let alive = true;
|
||||
void fetchSqReportStatus(taskId).then((s) => {
|
||||
void fetchSqReportCheck(taskId).then((result) => {
|
||||
if (!alive) return;
|
||||
// complete / unknown 都按「放行」(不打扰);仅明确 incomplete 才门控。
|
||||
const next: GateStatus = s === "incomplete" ? "incomplete" : "ok";
|
||||
const next: GateStatus = result.status === "incomplete" ? "incomplete" : "ok";
|
||||
statusRef.current = next;
|
||||
sqReportStatusRef.current = result.status;
|
||||
sqReportRef.current = result.status === "complete" ? (result.report ?? null) : null;
|
||||
if (next === "incomplete") {
|
||||
setOpen(true); // 进入即提醒(无论是否有挂起的自动发送)
|
||||
} else if (pendingStartRef.current) {
|
||||
// 加载期间已请求自动开始 → 放行后补发。
|
||||
const start = pendingStartRef.current;
|
||||
pendingStartRef.current = null;
|
||||
start();
|
||||
start(buildStartContext());
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
}, [buildStartContext, taskId]);
|
||||
|
||||
const gateStart = useCallback((start: () => void) => {
|
||||
const gateStart = useCallback((start: (context?: RwfxSqGateStartContext) => void) => {
|
||||
const status = statusRef.current;
|
||||
if (status === "ok") {
|
||||
start();
|
||||
start(buildStartContext());
|
||||
return;
|
||||
}
|
||||
// loading / incomplete:挂起。loading 解析为 ok 时补发;incomplete 时等用户「暂不」。
|
||||
pendingStartRef.current = start;
|
||||
if (status === "incomplete") setOpen(true);
|
||||
}, []);
|
||||
}, [buildStartContext]);
|
||||
|
||||
/** 暂不(不去 3q 分析)/ 关闭弹框 → 补发挂起的「开始问答」(无则无操作)。 */
|
||||
const stayAndStart = useCallback(() => {
|
||||
setOpen(false);
|
||||
const start = pendingStartRef.current;
|
||||
pendingStartRef.current = null;
|
||||
start?.();
|
||||
}, []);
|
||||
start?.(buildStartContext());
|
||||
}, [buildStartContext]);
|
||||
|
||||
const handleGo3qfx = async () => {
|
||||
const detail = readCopTaskDetail();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/light-apps";
|
||||
import { authStorageGet } from "@/core/auth/scoped-storage";
|
||||
import { readCurrentExternalAccessTokenValue } from "@/core/auth/external-access-token";
|
||||
|
||||
/** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */
|
||||
export const MOUNT_OPTIONS: { label: string; value: string }[] = [
|
||||
@@ -28,7 +29,8 @@ export function mountLabel(value: string | null | undefined): string {
|
||||
* userName / gateDepartName / typeName 取 consumer 接口 userDetailByYuserId 的返回值。
|
||||
*/
|
||||
export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: "accessToken", value: "accessToken" },
|
||||
{ label: "access_token", value: "access_token" },
|
||||
{ label: "accessToken(兼容旧配置)", value: "accessToken" },
|
||||
{ label: "yUserId", value: "yUserId" },
|
||||
{ label: "userName", value: "userName" },
|
||||
{ label: "gateDepartName", value: "gateDepartName" },
|
||||
@@ -142,6 +144,9 @@ export function resolveRouteParamValue(
|
||||
}
|
||||
if (param.source) {
|
||||
const value = userInfo[param.source];
|
||||
if (param.source === "access_token" || param.source === "accessToken") {
|
||||
return readCurrentExternalAccessTokenValue(userInfo);
|
||||
}
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
return "";
|
||||
@@ -153,15 +158,50 @@ export function resolveRouteParamValue(
|
||||
* 兼容 hash 路由地址(如 `https://x#/route`)—— 管理员粘贴完整地址,参数自动追加在末尾,
|
||||
* 落在 hash 路由的 query 上(与现有 XK/PG 页面 `?accessToken=` 拼接方式一致)。
|
||||
*/
|
||||
function appendQueryPairs(url: string, pairs: string[]): string {
|
||||
if (pairs.length === 0) return url;
|
||||
const hashIndex = url.indexOf("#");
|
||||
if (hashIndex >= 0) {
|
||||
const beforeHash = url.slice(0, hashIndex);
|
||||
const hash = url.slice(hashIndex);
|
||||
const sep = hash.includes("?") ? "&" : "?";
|
||||
return `${beforeHash}${hash}${sep}${pairs.join("&")}`;
|
||||
}
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${url}${sep}${pairs.join("&")}`;
|
||||
}
|
||||
|
||||
function replaceAccessTokenParam(url: string, accessToken: string): { url: string; replaced: boolean } {
|
||||
if (!accessToken) return { url, replaced: false };
|
||||
let replaced = false;
|
||||
const nextUrl = url.replace(/([?&]access_token)(?:=[^&#]*)?(?=[&#]|$)/g, (match) => {
|
||||
replaced = true;
|
||||
return `${match.split("=")[0]}=${encodeURIComponent(accessToken)}`;
|
||||
});
|
||||
return { url: nextUrl, replaced };
|
||||
}
|
||||
|
||||
export function buildLightAppUrl(
|
||||
app: Pick<LightApp, "url" | "routeParams">,
|
||||
userInfo: Record<string, unknown> = readUserInfo(),
|
||||
theme?: AppThemeColor,
|
||||
): string {
|
||||
const pairs = (app.routeParams ?? [])
|
||||
.filter((p) => p.key)
|
||||
.map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo, theme))}`);
|
||||
if (pairs.length === 0) return app.url;
|
||||
const sep = app.url.includes("?") ? "&" : "?";
|
||||
return `${app.url}${sep}${pairs.join("&")}`;
|
||||
const routeParams = app.routeParams ?? [];
|
||||
const accessToken = readCurrentExternalAccessTokenValue(userInfo);
|
||||
const tokenUrlResult = replaceAccessTokenParam(app.url, accessToken);
|
||||
const pairs = routeParams
|
||||
.filter((p) => (p.key ?? "").trim())
|
||||
.filter((p) => (p.key ?? "").trim() !== "access_token" || !tokenUrlResult.replaced)
|
||||
.map((p) => {
|
||||
const key = (p.key ?? "").trim();
|
||||
const value = key === "access_token" ? accessToken : resolveRouteParamValue(p, userInfo, theme);
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
});
|
||||
const hasAccessTokenParam =
|
||||
tokenUrlResult.replaced ||
|
||||
routeParams.some((p) => (p.key ?? "").trim() === "access_token");
|
||||
if (!hasAccessTokenParam && accessToken) {
|
||||
pairs.push(`${encodeURIComponent("access_token")}=${encodeURIComponent(accessToken)}`);
|
||||
}
|
||||
return appendQueryPairs(tokenUrlResult.url, pairs);
|
||||
}
|
||||
@@ -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
+105
-12
@@ -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
|
||||
|
||||
+4
-2
@@ -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())
|
||||
|
||||
+133
-19
@@ -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 -)。
|
||||
|
||||
+14
-9
@@ -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."
|
||||
|
||||
+88
@@ -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)
|
||||
+159
-33
@@ -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()
|
||||
|
||||
+95
-44
@@ -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:
|
||||
|
||||
+90
@@ -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",
|
||||
]
|
||||
+17
-16
@@ -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",
|
||||
]
|
||||
+326
-310
@@ -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:
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+63
@@ -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}
|
||||
|
||||
+1764
File diff suppressed because it is too large.
Load diff
+4252
File diff suppressed because it is too large.
Load diff
+3833
File diff suppressed because it is too large.
Load diff
+3484
File diff suppressed because it is too large.
Load diff
+2943
File diff suppressed because it is too large.
Load diff
+381
@@ -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>
|
||||
+3623
File diff suppressed because it is too large.
Load diff
+1432
File diff suppressed because it is too large.
Load diff
+98
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
+49
-7
@@ -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
|
||||
@@ -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
Reference in new issue
Block a user