Merge branch 'master-dev' into ji-dev

This commit is contained in:
Administrator committed 2026-06-29 15:12:24 +08:00
commit cd2ca406a5
103 files changed
+43649 -1979

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
VITE_MOCK_COP_TASK_DETAIL=true
VITE_MOCK_COP_TASK_DETAIL=flase
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_SQ_REPORT=true
@@ -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")}
@@ -107,33 +107,11 @@ function groupAgentsByTag(agents: Agent[], allowedTagIds: string[]): AgentGroup[
return groups;
}
/**
* 「按类型聚合智能体 + 聊天记录」左栏(需求 0616 #3 zzJC / #4 zzXD)。
*
* - 上半:可见智能体按标签(类型)分组、可折叠;点击进入该智能体对话(保持当前路由
* 命名空间,全量模式下为 /page/strategy)。
* - 下半:聊天记录 —— 仅与本模块智能体(即左栏列出的智能体)的会话,按 metadata.agent_id 过滤。
* - 宽度与通用问答左栏一致(``--sidebar-width``)。
*/
export function AgentModuleSidebar({
moduleKey,
activeAgentId,
onOpenSettings,
}: {
moduleKey: string;
activeAgentId?: string;
/** 传入则在左栏顶部显示设置齿轮(admin)。 */
onOpenSettings?: () => void;
}) {
const routeBase = useRouteBase();
const pathname = usePathname();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme();
const isDark = theme === "dark";
const { agents } = useAgents();
export function useAgentModuleSidebarData(moduleKey: string) {
const { agents, isLoading: agentsLoading } = useAgents();
const { data: threads = [] } = useThreads();
const { data: moduleConfig } = useAgentModuleConfig(moduleKey);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const { data: moduleConfig, isLoading: configLoading } =
useAgentModuleConfig(moduleKey);
const allowedTagIds = useMemo(
() => moduleConfig?.tag_ids ?? [],
@@ -161,6 +139,56 @@ export function AgentModuleSidebar({
[threads, moduleAgentIds],
);
return {
groups,
moduleAgentIds,
moduleThreads,
allowedTagIds,
// admin 是否已确认该模块展示的智能体类型(配置了标签)。
hasConfiguredType: allowedTagIds.length > 0,
isLoading: agentsLoading || configLoading,
};
}
/**
* 「按类型聚合智能体 + 聊天记录」左栏(需求 0616 #3 zzJC / #4 zzXD)。
*
* - 上半:可见智能体按标签(类型)分组、可折叠;点击进入该智能体对话(保持当前路由
* 命名空间,全量模式下为 /page/strategy)。
* - 下半:聊天记录 —— 仅与本模块智能体(即左栏列出的智能体)的会话,按 metadata.agent_id 过滤。
* - 宽度与通用问答左栏一致(``--sidebar-width``)。
*/
export function AgentModuleSidebar({
moduleKey,
activeAgentId,
activeThreadId,
routeBase: routeBaseProp,
headerTitle = "智能体",
className,
onOpenSettings,
}: {
moduleKey: string;
activeAgentId?: string;
activeThreadId?: string;
routeBase?: string;
headerTitle?: string;
className?: string;
/** 传入则在左栏顶部显示设置齿轮(admin)。 */
onOpenSettings?: () => void;
}) {
const contextRouteBase = useRouteBase();
const routeBase = routeBaseProp ?? contextRouteBase;
const pathname = usePathname();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme();
const isDark = theme === "dark";
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const { groups, moduleThreads, hasConfiguredType } =
useAgentModuleSidebarData(moduleKey);
// 已确认智能体类型时,允许把上半「智能体类型」整块收起,把空间让给下半聊天记录。
const [agentsSectionCollapsed, setAgentsSectionCollapsed] = useState(false);
const agentsCollapsed = hasConfiguredType && agentsSectionCollapsed;
const toggleGroup = (key: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
@@ -169,14 +197,68 @@ export function AgentModuleSidebar({
return next;
});
// 已确认类型时把所有分组的智能体拍平(单选类型一般只有一个分组),
// 标题行直接显示该类型名 + 数量,去掉额外的分组行。
const flatAgents = useMemo(() => groups.flatMap((g) => g.agents), [groups]);
const configuredTypeLabel =
groups.length === 1 ? (groups[0]?.label ?? "智能体类型") : "智能体类型";
const renderAgentCard = (agent: Agent, keyPrefix: string) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
return (
<Link
key={`${keyPrefix}:${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",
isActive
? "border-primary/40 bg-primary/5"
: "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"
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
>
<Bot className="size-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-semibold",
isActive ? "text-primary" : "text-foreground",
)}
>
{desensitize(agent.name)}
</span>
<span className="mt-0.5 block truncate text-[11px] leading-snug 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" />
)}
</Link>
);
};
return (
// 原有布局不变,仅做配色适配:深色下左栏改为半透明深蓝玻璃面板(对齐 .sidebar-contents:
// bg rgba(3,38,80,.8) + 蓝色发光边框),叠 backdrop-blur 让外壳的深蓝底图朦胧透出;
// 浅色保持白底。其余颜色已走语义 token 自适配。
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
<aside
className={cn(
"flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80",
className,
)}
>
{onOpenSettings && (
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-xs font-semibold text-foreground/80">智能体</span>
<span className="text-xs font-semibold text-foreground/80">
{headerTitle}
</span>
<button
type="button"
onClick={onOpenSettings}
@@ -189,89 +271,116 @@ export function AgentModuleSidebar({
</div>
)}
{/* 上半:按类型分组的智能体(彩色卡片,与全量版看齐) */}
<div className="flex min-h-0 flex-[3] flex-col overflow-y-auto px-2 py-3">
<h2 className="mb-1 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none">
智能体类型
</h2>
{groups.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">暂无智能体</p>
{/* 上半:智能体列表。
- 未确认类型:按类型分组、可逐组折叠(与全量版一致)。
- 已确认类型:标题行直接显示该类型名 + 数量(去掉单独的分组行),
扁平列出该类型下的智能体,并可整块收起(grid 行高过渡动画),
收起后只留标题、把高度让给下半聊天记录。 */}
<div
className={cn(
"flex flex-col px-2 py-3",
agentsCollapsed ? "shrink-0" : "min-h-0 flex-[3]",
)}
>
{hasConfiguredType ? (
<>
<button
type="button"
onClick={() => setAgentsSectionCollapsed((prev) => !prev)}
title={agentsCollapsed ? "展开智能体" : "收起智能体"}
className="flex shrink-0 items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5 truncate">
<span className="truncate">{configuredTypeLabel}</span>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{flatAgents.length}
</span>
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-300",
agentsCollapsed && "-rotate-90",
)}
/>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-in-out",
agentsCollapsed
? "grid-rows-[0fr]"
: "mt-1 min-h-0 flex-1 grid-rows-[1fr]",
)}
>
<div
className={cn(
"min-h-0",
agentsCollapsed
? "overflow-hidden"
: "overflow-y-auto overflow-x-hidden",
)}
>
{flatAgents.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">
暂无智能体
</p>
) : (
<div className="flex flex-col gap-1">
{flatAgents.map((agent) =>
renderAgentCard(agent, "configured"),
)}
</div>
)}
</div>
</div>
</>
) : (
<div className="flex flex-col gap-1.5">
{groups.map((group) => {
const isCollapsed = collapsed.has(group.key);
return (
<div key={group.key}>
<button
type="button"
onClick={() => toggleGroup(group.key)}
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5 truncate">
<span className="truncate">{group.label}</span>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{group.agents.length}
</span>
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
isCollapsed && "-rotate-90",
)}
/>
</button>
{!isCollapsed && (
<div className="mt-1 flex flex-col gap-1">
{group.agents.map((agent) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
return (
<Link
key={`${group.key}:${agent.id}`}
href={href}
<>
<h2 className="mb-1 shrink-0 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none">
智能体类型
</h2>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
{groups.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">
暂无智能体
</p>
) : (
<div className="flex flex-col gap-1.5">
{groups.map((group) => {
const isCollapsed = collapsed.has(group.key);
return (
<div key={group.key}>
<button
type="button"
onClick={() => toggleGroup(group.key)}
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5 truncate">
<span className="truncate">{group.label}</span>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{group.agents.length}
</span>
</span>
<ChevronDown
className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors",
isActive
? "border-primary/40 bg-primary/5"
: "border-transparent hover:border-border hover:bg-accent/50",
"size-3.5 shrink-0 transition-transform duration-200",
isCollapsed && "-rotate-90",
)}
>
<span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
}}
>
<Bot className="size-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-semibold",
isActive ? "text-primary" : "text-foreground",
)}
>
{desensitize(agent.name)}
</span>
<span className="mt-0.5 block truncate text-[11px] leading-snug 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" />
/>
</button>
{!isCollapsed && (
<div className="mt-1 flex flex-col gap-1">
{group.agents.map((agent) =>
renderAgentCard(agent, group.key),
)}
</Link>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
)}
</div>
</>
)}
</div>
@@ -286,7 +395,8 @@ export function AgentModuleSidebar({
<div className="flex flex-col gap-0.5">
{moduleThreads.map((thread) => {
const href = pathOfThread(thread, undefined, routeBase);
const isActive = href === pathname;
const isActive =
thread.thread_id === activeThreadId || href === pathname;
return (
<Link
key={thread.thread_id}
@@ -45,11 +45,32 @@ function skillLabel(skill: Skill) {
function matchesQuery(skill: Skill, q: string) {
if (!q) return true;
return (
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q)
);
const haystack = [
skill.name,
skill.name_zh,
skill.description,
skill.detail,
skill.category,
skill.license,
skill.source,
skill.state,
skill.owner_username,
skill.created_at,
skill.last_activity_at,
skill.use_count != null ? `使用 ${skill.use_count}` : null,
skill.view_count != null ? `查看 ${skill.view_count}` : null,
skill.patch_count != null ? `变迁 ${skill.patch_count}` : null,
...(skill.tags ?? []).flatMap((tag) => [
tag.name,
tag.description,
tag.color,
tag.usage_count != null ? `标签使用 ${tag.usage_count}` : null,
]),
]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.join(" ")
.toLowerCase();
return haystack.includes(q);
}
export function SkillTransferSelector({
@@ -159,7 +180,7 @@ export function SkillTransferSelector({
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索全部技能(中英文名称、描述)…"
placeholder="搜索技能名称、描述、分类、标签、变迁..."
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
@@ -239,7 +260,7 @@ export function SkillTransferSelector({
{/* Two-column transfer panes */}
<div className="grid grid-cols-2 gap-3">
<SkillPane
title="可用技能"
title="未勾选技能"
count={availableSkills.length}
items={availableView}
variant="available"
@@ -248,10 +269,10 @@ export function SkillTransferSelector({
onPageChange={setAvailablePage}
onPick={add}
onViewDetail={onViewDetail}
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部启用"}
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部勾选"}
/>
<SkillPane
title="已启用技能"
title="已勾选技能"
count={selectedSkills.length}
items={selectedView}
variant="selected"
@@ -260,7 +281,7 @@ export function SkillTransferSelector({
onPageChange={setSelectedPage}
onPick={remove}
onViewDetail={onViewDetail}
emptyHint={query ? "没有匹配的已选技能" : "尚未启用任何技能"}
emptyHint={query ? "没有匹配的已选技能" : "尚未勾选任何技能"}
/>
</div>
</div>
@@ -49,9 +49,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,
@@ -79,7 +78,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";
@@ -232,7 +232,7 @@ export function ArtifactFileDetail({
const [viewMode, setViewMode] = useState<"code" | "preview">("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);
@@ -432,29 +432,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;
}
@@ -686,18 +664,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}
@@ -782,7 +759,7 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
@@ -859,10 +836,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>
);
}
@@ -470,11 +470,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
</Button>
</div>
</header>
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<div className="flex items-center gap-2">
<ViewModeToggle value={viewMode} onChange={setViewMode} />
@@ -497,11 +497,16 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
</div>
}
/>
{publisher && (
<div className="flex items-center gap-2">
</div>
}
/>
{!isLoading && !error && (
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
共 {orderedSkills.length} 个技能
</div>
)}
{publisher && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span>
<button
type="button"
@@ -11,6 +11,7 @@ import {
GaugeIcon,
HistoryIcon,
MessageCircleQuestionIcon,
PlugIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
@@ -218,6 +219,14 @@ export function WorkspaceNavMenu() {
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/plugins")}
>
<PlugIcon />
插件管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/positions")}
@@ -0,0 +1,7 @@
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;
}
+110 -17
View File
@@ -1,5 +1,6 @@
import { requestCrudService } from "@/service/request-axios-service";
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || "";
@@ -8,6 +9,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 +18,102 @@ 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 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>,
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 incomingAccessToken = normalizeExternalAccessToken(result.accessToken);
if (incomingAccessToken) {
next.accessToken = incomingAccessToken;
return next;
}
const previousYUserId = readStoredYUserId(previous);
const previousAccessToken =
previousYUserId === yUserId ? normalizeExternalAccessToken(previous.accessToken) : "";
const cachedAccessToken = normalizeExternalAccessToken(cached.accessToken);
const fallbackAccessToken = previousAccessToken || cachedAccessToken;
if (fallbackAccessToken) {
next.accessToken = fallbackAccessToken;
} else {
delete next.accessToken;
}
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.
@@ -38,10 +137,11 @@ export async function loginByYUserId(
// 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的
// access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。
const result = res.data ?? {};
authStorageSet(
EXTERNAL_USER_INFO_STORAGE_KEY,
JSON.stringify({ ...result, yUserId }),
);
const previous = readExternalUserInfo();
const cached = readCachedYUserInfo(yUserId);
const next = buildExternalUserInfo(yUserId, result, previous, cached);
authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(next));
writeCachedYUserInfo(yUserId, next);
}
export function persistToolsToken(token: string): void {
@@ -56,20 +156,13 @@ export function persistToolsToken(token: string): void {
* 用合并而非覆盖整对象,避免抹掉 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 = {};
}
const info = readExternalUserInfo();
info.accessToken = trimmed;
authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info));
const yUserId = readStoredYUserId(info);
if (yUserId) {
writeCachedYUserInfo(yUserId, info);
}
}
@@ -0,0 +1,168 @@
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,
},
);
});
@@ -0,0 +1,135 @@
export type DomainAgentMapping = {
id: string;
domain: string;
agentId: string;
enabled: boolean;
};
function trimToUndefined(value: string | null | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeAgentId(agentId: string | null | undefined): string | undefined {
const trimmed = agentId?.trim();
return trimmed ? trimmed : undefined;
}
export function normalizeDomainInput(
value: string | null | undefined,
): string | undefined {
const trimmed = trimToUndefined(value);
if (!trimmed) return undefined;
const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)
? trimmed
: `https://${trimmed}`;
try {
const parsed = new URL(candidate);
const host = parsed.host.trim().toLowerCase();
return host || undefined;
} catch {
return undefined;
}
}
export function resolveDomainMappedAgentId({
pageUrl,
mappings,
availableAgentIds,
}: {
pageUrl: string | null | undefined;
mappings: DomainAgentMapping[];
availableAgentIds?: readonly string[] | null;
}): string | undefined {
const host = normalizeDomainInput(pageUrl);
if (!host) return undefined;
const allowedAgents =
availableAgentIds && availableAgentIds.length > 0
? new Set(availableAgentIds)
: null;
for (const mapping of mappings) {
if (!mapping.enabled) continue;
if (normalizeDomainInput(mapping.domain) !== host) continue;
if (allowedAgents && !allowedAgents.has(mapping.agentId)) continue;
return mapping.agentId;
}
return undefined;
}
export function resolveConfiguredIframeAgentId({
pageUrl,
mappings,
availableAgentIds,
isNewThread,
hasStartedConversation,
manualSelectionLocked,
}: {
pageUrl: string | null | undefined;
mappings: DomainAgentMapping[];
availableAgentIds?: readonly string[] | null;
isNewThread: boolean;
hasStartedConversation: boolean;
manualSelectionLocked: boolean;
}): string | undefined {
if (!isNewThread || hasStartedConversation || manualSelectionLocked) {
return undefined;
}
return resolveDomainMappedAgentId({
pageUrl,
mappings,
availableAgentIds,
});
}
export function resolveIncomingIframeSelection({
pageUrl,
mappings,
availableAgentIds,
manualSelectedAgentId,
}: {
pageUrl: string | null | undefined;
mappings: DomainAgentMapping[];
availableAgentIds?: readonly string[] | null;
manualSelectedAgentId: string | null;
}): {
selectedAgentId: string | null;
forceDefaultAssistant: boolean;
} {
const mappedAgentId = resolveDomainMappedAgentId({
pageUrl,
mappings,
availableAgentIds,
});
if (mappedAgentId) {
return {
selectedAgentId: mappedAgentId,
forceDefaultAssistant: false,
};
}
const allowedAgents =
availableAgentIds && availableAgentIds.length > 0
? new Set(availableAgentIds)
: null;
const normalizedManualAgentId = normalizeAgentId(manualSelectedAgentId);
if (
normalizedManualAgentId &&
(!allowedAgents || allowedAgents.has(normalizedManualAgentId))
) {
return {
selectedAgentId: normalizedManualAgentId,
forceDefaultAssistant: false,
};
}
return {
selectedAgentId: null,
forceDefaultAssistant: true,
};
}
@@ -8,7 +8,7 @@ function makeSources(count: number): RuntimeDisplaySource[] {
index: position + 1,
skillName: "knowledge-base-search",
mode: "citation",
title: `参考文献 ${position + 1}`,
title: `Reference ${position + 1}`,
type: "result",
snippet: "",
raw: {},
@@ -19,7 +19,7 @@ function citation(index: number): string {
return `[citation:${index}](#reference-${index})`;
}
test("全角单编号 【1】【2】 解析为引用徽章", () => {
test("converts full-width numbered references", () => {
const out = applyInlineCitations("结论见【1】【2】。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
@@ -27,21 +27,21 @@ test("全角单编号 【1】【2】 解析为引用徽章", () => {
assert.equal(out.includes("【2】"), false);
});
test("全角数字 【1】 也能识别", () => {
test("converts full-width digits", () => {
const out = applyInlineCitations("参考【1】和【2】。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
test("全角括号合并编号 【1,2】/【1,2】/【1、2】 拆成多个引用", () => {
for (const marker of ["【1,2】", "【1,2】", "【1、2】"]) {
test("splits merged reference markers", () => {
for (const marker of ["【1,2】", "【1、2】", "【1,2】"]) {
const out = applyInlineCitations(`见${marker}。`, makeSources(2));
assert.equal(out.includes(citation(1)), true, marker);
assert.equal(out.includes(citation(2)), true, marker);
}
});
test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => {
test("expands reference ranges", () => {
const dash = applyInlineCitations("见【1-3】。", makeSources(3));
assert.equal(dash.includes(citation(1)), true);
assert.equal(dash.includes(citation(2)), true);
@@ -53,21 +53,26 @@ test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => {
assert.equal(tilde.includes(citation(4)), true);
});
test("超出来源范围的全角编号保持原样,不误判正文", () => {
// 只有 1 条来源,【3】 没有对应来源 → 不应转换
test("does not rewrite out-of-range references", () => {
const out = applyInlineCitations("正文【3】不是引用。", makeSources(1));
assert.equal(out.includes("【3】"), true);
assert.equal(out.includes("#reference-3"), false);
});
test("非数字的全角括号不被改写", () => {
test("does not rewrite non-numeric bracket text", () => {
const out = applyInlineCitations("见【附录】小节。", makeSources(2));
assert.equal(out.includes("【附录】"), true);
});
test("半角写法仍然正常工作", () => {
test("keeps half-width syntax working", () => {
const out = applyInlineCitations("见[1][2]和[1,3]。", makeSources(3));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
assert.equal(out.includes(citation(3)), true);
});
test("accepts spaced and full-width square brackets", () => {
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
import type { Skill } from "@/core/skills/type";
import { buildSkillDisplayMap, collectRuntimeDisplaySources } from "./runtime";
function makeSkill(): Skill {
return {
name: "custom-search",
description: "test",
category: "test",
license: "internal",
enabled: true,
display: {
enabled: true,
mode: "citation",
resultPath: "docs",
titleField: "title",
summaryField: "summary",
contentField: "content",
sourceField: "source",
authorField: null,
typeField: null,
timeField: null,
scoreField: null,
urlField: "url",
idField: "id",
linkStrategy: "auto",
linkTemplate: null,
citationToken: "doubleBracket",
maxItems: 20,
customInstruction: "",
tableColumns: [],
resultSets: [],
},
};
}
function makeMessages(toolContent: Message["content"]): Message[] {
return [
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [{ id: "call-1", name: "custom-search", args: {} }],
} as unknown as Message,
{
id: "tool-1",
type: "tool",
name: "custom-search",
tool_call_id: "call-1",
content: toolContent,
} as unknown as Message,
];
}
test("collects citations from fenced nested json", () => {
const messages = makeMessages(
"```json\n{\"payload\":{\"docs\":[{\"id\":\"doc-1\",\"title\":\"Alpha\",\"summary\":\"Brief\",\"content\":\"Body\",\"source\":\"KB\",\"url\":\"https://example.com/a\"}]}}\n```",
);
const sources = collectRuntimeDisplaySources(
messages,
buildSkillDisplayMap([makeSkill()]),
);
assert.equal(sources.length, 1);
assert.equal(sources[0]?.title, "Alpha");
assert.equal(sources[0]?.url, "https://example.com/a");
});
test("collects citations from structured tool payload arrays", () => {
const messages = makeMessages([
{
type: "json",
json: {
wrapper: {
docs: [
{
id: "doc-2",
title: "Beta",
summary: "Second",
content: "Payload",
source: "Skill",
url: "https://example.com/b",
},
],
},
},
},
] as unknown as Message["content"]);
const sources = collectRuntimeDisplaySources(
messages,
buildSkillDisplayMap([makeSkill()]),
);
assert.equal(sources.length, 1);
assert.equal(sources[0]?.title, "Beta");
assert.equal(sources[0]?.source, "Skill");
});
+146 -26
View File
@@ -588,7 +588,7 @@ function replaceBracketCitationTokens(
byIndex: Map<number, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string {
const tokenPattern = /\\?\[(\d{1,3})\\?\](?!\()/g;
const tokenPattern = /\\?\[\s*([0-9\uFF10-\uFF19]{1,3})\s*\\?\](?!\()/g;
let cursor = 0;
let next = "";
for (const match of content.matchAll(tokenPattern)) {
@@ -598,13 +598,24 @@ function replaceBracketCitationTokens(
continue;
}
next += content.slice(cursor, start);
next += citationMarkdown(match[0], Number(match[1]), byIndex, orderedSources);
next += citationMarkdown(
match[0],
Number(normalizeAsciiDigits(match[1] ?? "")),
byIndex,
orderedSources,
);
cursor = start + match[0].length;
}
next += content.slice(cursor);
return next;
}
function normalizeAsciiDigits(value: string): string {
return value.replace(/[\uFF10-\uFF19]/g, (digit) =>
String.fromCharCode(digit.charCodeAt(0) - 0xfee0),
);
}
function normalizeLooseCitationTokens(
content: string,
byIndex: Map<number, RuntimeDisplaySource>,
@@ -637,13 +648,17 @@ function normalizeLooseCitationTokens(
// 避免把正文里的【…】误判成引用。转成半角后交给下面的半角逻辑统一处理。
// 全角单编号:【1】/【1】
let next = content.replace(
let next = content
.replace(/[/g, "[")
.replace(/]/g, "]")
.replace(/[,、]/g, ",")
.replace(
/【\s*([0-90-9]{1,3})\s*】/g,
(match, rawIndex: string) => {
const index = Number(toAsciiDigits(rawIndex));
const index = Number(normalizeAsciiDigits(rawIndex));
return isValidIndex(index) ? `[${index}]` : match;
},
);
);
// 全角合并编号:【1,2】/【1,2】/【1、2】
next = next.replace(
@@ -651,7 +666,7 @@ function normalizeLooseCitationTokens(
(match, rawList: string) => {
const indexes = rawList
.split(/[,,、]/)
.map((part) => Number(toAsciiDigits(part.trim())));
.map((part) => Number(normalizeAsciiDigits(part.trim())));
if (indexes.some((index) => !isValidIndex(index))) {
return match;
}
@@ -663,8 +678,8 @@ function normalizeLooseCitationTokens(
next = next.replace(
/【\s*([0-90-9]{1,3})\s*[-–~~]\s*([0-90-9]{1,3})\s*】/g,
(match, rawStart: string, rawEnd: string) => {
const start = Number(toAsciiDigits(rawStart));
const end = Number(toAsciiDigits(rawEnd));
const start = Number(normalizeAsciiDigits(rawStart));
const end = Number(normalizeAsciiDigits(rawEnd));
if (
end <= start ||
end - start > 20 ||
@@ -978,20 +993,23 @@ function extractStructuredToolPayload(content: unknown): unknown {
}
function unwrapToolPayloadObject(value: Record<string, unknown>): unknown {
if (
Array.isArray(value.results) ||
Array.isArray(value.data) ||
Array.isArray(value.items)
) {
return value;
const directCandidate = findDisplayPayloadCandidate(value, 0);
if (directCandidate !== undefined) {
return directCandidate;
}
for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) {
const nested = value[key];
for (const nested of Object.values(value)) {
if (typeof nested === "string") {
const parsed = parseJSONLikeText(nested);
if (parsed !== undefined) {
if (isRecord(parsed)) {
const structured = findDisplayPayloadCandidate(parsed, 0);
if (structured !== undefined) {
return structured;
}
}
return parsed;
}
continue;
}
if (isRecord(nested)) {
const unwrapped = unwrapToolPayloadObject(nested);
@@ -1008,6 +1026,14 @@ function parseJSONLikeText(text: string): unknown {
if (!trimmed) {
return undefined;
}
const fenced = extractFencedJSON(trimmed);
if (fenced) {
try {
return JSON.parse(fenced) as unknown;
} catch {
// Fall through to best-effort parsing.
}
}
try {
return JSON.parse(trimmed) as unknown;
} catch {
@@ -1016,17 +1042,9 @@ function parseJSONLikeText(text: string): unknown {
}
function extractJSONFromText(text: string): unknown {
const candidates: Array<[number, number]> = [];
const firstObj = text.indexOf("{");
if (firstObj !== -1) candidates.push([firstObj, text.lastIndexOf("}")]);
const firstArr = text.indexOf("[");
if (firstArr !== -1) candidates.push([firstArr, text.lastIndexOf("]")]);
candidates.sort(([a], [b]) => a - b);
for (const [start, end] of candidates) {
if (start < 0 || end <= start) continue;
const slice = text.slice(start, end + 1);
for (const candidate of iterateBalancedJsonCandidates(text)) {
try {
return JSON.parse(slice);
return JSON.parse(candidate);
} catch {
// try the next candidate
}
@@ -1034,6 +1052,108 @@ function extractJSONFromText(text: string): unknown {
return undefined;
}
function *iterateBalancedJsonCandidates(text: string): Generator<string> {
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (char !== "{" && char !== "[") {
continue;
}
const candidate = extractBalancedJson(text, index);
if (candidate) {
yield candidate;
}
}
}
function extractBalancedJson(text: string, start: number): string | null {
const opening = text[start];
if (opening !== "{" && opening !== "[") {
return null;
}
const closing = opening === "{" ? "}" : "]";
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < text.length; index++) {
const char = text[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
continue;
}
if (char === opening) {
depth += 1;
continue;
}
if (char === closing) {
depth -= 1;
if (depth === 0) {
return text.slice(start, index + 1);
}
}
}
return null;
}
function extractFencedJSON(text: string): string | null {
const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
return match?.[1]?.trim() ?? null;
}
function findDisplayPayloadCandidate(value: unknown, depth: number): unknown {
if (depth > 8) {
return undefined;
}
if (Array.isArray(value)) {
const objectItems = value.filter(
(item): item is Record<string, unknown> =>
isRecord(item),
);
if (value.length === 0 || objectItems.length === value.length) {
return { results: objectItems };
}
for (const item of value) {
const nested = findDisplayPayloadCandidate(item, depth + 1);
if (nested !== undefined) {
return nested;
}
}
return undefined;
}
if (!isRecord(value)) {
return undefined;
}
for (const key of ["results", "data", "items", "docs", "records", "rows", "list", "hits", "sources"]) {
const nested = value[key];
if (!Array.isArray(nested)) {
continue;
}
const objectItems = nested.filter(
(item): item is Record<string, unknown> =>
isRecord(item),
);
if (nested.length === 0 || objectItems.length === nested.length) {
return { ...value, [key]: objectItems };
}
}
for (const nested of Object.values(value)) {
const candidate = findDisplayPayloadCandidate(nested, depth + 1);
if (candidate !== undefined) {
return candidate;
}
}
return undefined;
}
function extractSearchPayloadFromText(text: string): unknown {
if (!text.includes('"results"') || !text.includes('"recUuid"')) {
return undefined;
+54 -16
View File
@@ -141,17 +141,39 @@ export async function getSkillDisplaySample(
return response.json() as Promise<SkillDisplaySampleResponse>;
}
export interface InstallSkillRequest {
thread_id: string;
path: string;
}
export interface InstallSkillResponse {
success: boolean;
skill_name: string;
message: string;
overwritten?: boolean;
}
export interface InstallSkillRequest {
thread_id: string;
path: string;
skill_name?: string;
}
export interface InstallSkillResponse {
success: boolean;
skill_name: string;
message: string;
overwritten?: boolean;
}
export interface PreviewSkillInstallRequest {
thread_id: string;
path: string;
}
export interface PreviewSkillInstallResponse {
ok: boolean;
skill_name: string;
description: string;
license: string | null;
allowed_tools: string[];
can_install_as_is: boolean;
owned_by_me: boolean;
owned_by_other: boolean;
builtin_conflict: boolean;
owner_username?: string | null;
owner_user_id?: string | null;
message: string;
warnings: string[];
}
export interface ValidateSkillUploadResponse {
ok: boolean;
@@ -164,9 +186,9 @@ export interface ValidateSkillUploadResponse {
warnings: string[];
}
export async function installSkill(
request: InstallSkillRequest,
): Promise<InstallSkillResponse> {
export async function installSkill(
request: InstallSkillRequest,
): Promise<InstallSkillResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install`, {
method: "POST",
headers: {
@@ -187,8 +209,24 @@ export async function installSkill(
};
}
return response.json();
}
return response.json();
}
export async function previewSkillInstall(
request: PreviewSkillInstallRequest,
): Promise<PreviewSkillInstallResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install-preview`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw await parseDetailedError(response, "Failed to preview skill install");
}
return response.json() as Promise<PreviewSkillInstallResponse>;
}
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
const data = await response.json().catch(() => ({}));
@@ -1,6 +1,7 @@
/** Strategy 模块外部 iframe / 嵌入页链接(来自 Vite 环境变量) */
import { authStorageGet } from "@/core/auth/scoped-storage";
import { normalizeExternalAccessToken } 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 normalizeExternalAccessToken(readUserInfo().accessToken);
}
/**
@@ -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`,
@@ -342,6 +445,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 +476,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,
+192 -59
View File
@@ -43,9 +43,13 @@ export type ThreadStreamOptions = {
onToolEnd?: (event: ToolEndEvent) => void;
};
type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>;
};
type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>;
};
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
title_provisional?: boolean;
};
function normalizeStoredRunId(runId: string | null): string | null {
if (!runId) {
@@ -155,7 +159,7 @@ export async function stopThreadRun(
}
}
function getStreamErrorMessage(error: unknown): string {
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) {
return error;
}
@@ -175,8 +179,31 @@ function getStreamErrorMessage(error: unknown): string {
return nestedError;
}
}
return "Request failed.";
}
return "Request failed.";
}
async function fetchThreadTitleRefreshState(
threadId: string,
signal?: AbortSignal,
): Promise<ThreadTitleRefreshState | null> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/state`,
{ signal },
);
if (!response.ok) {
return null;
}
const json = (await response.json()) as {
values?: { title?: string; title_provisional?: boolean };
};
if (!json.values) {
return null;
}
return {
title: json.values.title ?? "",
title_provisional: json.values.title_provisional,
};
}
/**
* Detect "thread not found" / forbidden errors from the LangGraph SDK.
@@ -224,15 +251,24 @@ function isRunNotFoundError(message: string): boolean {
* 「already has an active run」。匹配 HTTP 409、英文兜底文案,或后端已中文化
* 的提示,统一映射成一句友好的中文。
*/
function isActiveRunConflictError(message: string): boolean {
return (
/\b409\b/.test(message) ||
/already has an active run/i.test(message) ||
/还在生成回答/.test(message)
);
}
function buildCurrentUserLabel(): string {
function isActiveRunConflictError(message: string): boolean {
return (
/\b409\b/.test(message) ||
/already has an active run/i.test(message) ||
/还在生成回答/.test(message)
);
}
function isModelConcurrencyLimitError(message: string): boolean {
return (
/\b429\b/.test(message) ||
/并发已达到上限/.test(message) ||
/使用人数较多/.test(message) ||
/too many requests/i.test(message)
);
}
function buildCurrentUserLabel(): string {
const auth = getStoredAuth();
if (!auth) {
return "未登录";
@@ -570,12 +606,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 +623,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 +656,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 +674,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 +1020,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 +1403,29 @@ export function useRenameThread() {
mutationFn: async ({
threadId,
title,
}: {
threadId: string;
title: string;
}) => {
await apiClient.threads.updateState(threadId, {
values: { title },
});
},
onSuccess(_, { threadId, title }) {
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
}: {
threadId: string;
title: string;
}) => {
await apiClient.threads.updateState(threadId, {
values: { title, title_provisional: false },
});
},
onSuccess(_, { threadId, title }) {
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
t.thread_id === threadId
? {
...t,
values: {
...t.values,
title,
},
}
: t,
),
);
values: {
...t.values,
title,
title_provisional: false,
},
}
: t,
),
);
},
});
}
+7 -6
View File
@@ -2,12 +2,13 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
import type { Todo } from "../todos";
export interface AgentThreadState extends Record<string, unknown> {
title: string;
messages: Message[];
artifacts: string[];
todos?: Todo[];
}
export interface AgentThreadState extends Record<string, unknown> {
title: string;
title_provisional?: boolean;
messages: Message[];
artifacts: string[];
todos?: Todo[];
}
export interface AgentThreadContext extends Record<string, unknown> {
thread_id: string;
+3 -1
View File
@@ -1,7 +1,7 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export type RewriteAction = "polish" | "expand" | "shorten" | "tone";
export type RewriteAction = "polish" | "expand" | "shorten" | "tone" | "imitate";
export type RewriteTone =
| "professional"
| "casual"
@@ -17,6 +17,8 @@ export interface RewriteRequest {
modelName?: string;
surroundingContext?: { before?: string; after?: string };
customInstruction?: string;
/** 仿写参考范文(action="imitate" 时使用):AI 模仿其文风/句式/语气改写所选文字。 */
referenceText?: string;
followUpInstruction?: string;
previousResult?: string;
}
@@ -40,11 +40,12 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
type MaterialSource = 'general' | 'notebook';
type MaterialSource = 'general' | 'knowledge_base' | 'notebook';
const MAX_INTENT_LENGTH = 500;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口,不走技能编排' },
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
];
@@ -6,7 +6,8 @@ import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' },
// 刷新=重置后 in_progress 会话已无活进程,显示中性「未完成」而非「进行中」,避免歧义。
in_progress: { label: '未完成', color: 'text-slate-400' },
// 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。
background: { label: '挂起中', color: 'text-sky-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
@@ -1,88 +1,370 @@
import { useMemo } from 'react';
import { CheckCircle2, ChevronRight, FileText, Search, PenLine, ClipboardEdit, Star } from 'lucide-react';
import type { AIWritingSessionSummary, CompletedInterventionRecord } from '../../api/ai-writing-sessions';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
import {
normalizeProgressDict,
normalizeMaterial,
normalizeOutline,
normalizeReviewResult,
} from '../../hooks/useAIWritingStream';
import type { CompletedIntervention } from '../../types/ai-writing';
import type { CompletedIntervention, ProgressEvent, ReviewResult } from '../../types/ai-writing';
import { countArticleWords } from '../../utils/wordCount';
import { AIWritingTimeline } from './AIWritingTimeline';
/**
* 归一化一条 completedIntervention(后台挂起重建的干预记录里素材/大纲/审核数据是
* snake_case,前端干预卡期望 camelCase)。对前端实时存的 camelCase 记录幂等。
*/
function normalizeCompletedIntervention(rec: Record<string, unknown>): CompletedIntervention {
const out: Record<string, unknown> = { ...rec };
out.pausePoint = rec.pausePoint ?? rec.pause_point;
const PAUSE_POINTS = new Set<CompletedIntervention['pausePoint']>([
'material_confirm',
'outline_confirm',
'draft_confirm',
'review_confirm',
'section_help',
]);
const PAUSE_MESSAGES: Record<CompletedIntervention['pausePoint'], string> = {
material_confirm: '素材收集专家已完成素材搜集,请确认素材后继续。',
outline_confirm: '作家已完成大纲规划,请确认或编辑大纲后继续。',
draft_confirm: '草稿已完成,请确认或给出修改意见。',
review_confirm: '编辑认为文章需要修改,请决定下一步操作。',
section_help: '部分章节缺少足够素材支撑写作,请决定如何处理。',
};
interface TimelineSnapshot {
materials?: CompletedIntervention['materials'];
outline?: CompletedIntervention['outline'];
draftTitle?: string;
draftWordCount?: number;
reviewResult?: ReviewResult | null;
}
function asPausePoint(value: unknown): CompletedIntervention['pausePoint'] | null {
const pausePoint = String(value ?? '');
return PAUSE_POINTS.has(pausePoint as CompletedIntervention['pausePoint'])
? (pausePoint as CompletedIntervention['pausePoint'])
: null;
}
function normalizeCompletedIntervention(rec: Record<string, unknown>): CompletedIntervention | null {
const pausePoint = asPausePoint(rec.pausePoint ?? rec.pause_point);
if (!pausePoint) return null;
const out: CompletedIntervention = {
pausePoint,
action: String(rec.action ?? defaultActionForPause(pausePoint)),
};
if (Array.isArray(rec.materials)) {
out.materials = (rec.materials as Record<string, unknown>[]).map(normalizeMaterial);
}
if (rec.outline) out.outline = normalizeOutline(rec.outline);
const rr = rec.reviewResult ?? rec.review_result;
if (rr) out.reviewResult = normalizeReviewResult(rr);
if (out.draftTitle == null && rec.draft_title != null) out.draftTitle = rec.draft_title;
if (out.draftWordCount == null && rec.draft_word_count != null) out.draftWordCount = rec.draft_word_count;
if (out.userInput == null && rec.user_input != null) out.userInput = rec.user_input;
return out as unknown as CompletedIntervention;
const outline = normalizeOutline(rec.outline);
if (outline) out.outline = outline;
const reviewResult = normalizeReviewResult(rec.reviewResult ?? rec.review_result);
if (reviewResult) out.reviewResult = reviewResult;
const draftTitle = rec.draftTitle ?? rec.draft_title;
if (typeof draftTitle === 'string' && draftTitle.trim()) out.draftTitle = draftTitle;
const draftWordCount = Number(rec.draftWordCount ?? rec.draft_word_count);
if (Number.isFinite(draftWordCount) && draftWordCount > 0) {
out.draftWordCount = draftWordCount;
}
const userInput =
rec.userInput ??
rec.user_input ??
rec.userQuery ??
rec.user_query ??
rec.outlineFeedback ??
rec.outline_feedback ??
rec.userRevisionNotes ??
rec.user_revision_notes;
if (typeof userInput === 'string' && userInput.trim()) out.userInput = userInput;
return out;
}
const PAUSE_LABELS: Record<string, string> = {
material_confirm: '素材确认',
outline_confirm: '大纲确认',
draft_confirm: '草稿确认',
review_confirm: '编辑意见处理',
};
function normalizeInterventionList(records: unknown): CompletedIntervention[] {
if (!Array.isArray(records)) return [];
return records
.map((rec) => (
rec && typeof rec === 'object'
? normalizeCompletedIntervention(rec as Record<string, unknown>)
: null
))
.filter((rec): rec is CompletedIntervention => rec != null);
}
const ACTION_LABELS: Record<string, string> = {
approve: '已批准',
re_search: '重新搜索',
re_outline: '重新规划',
finalize: '直接定稿',
to_editor: '提交审核',
user_revise: '手动修改',
accept_review: '接受意见',
force_finalize: '强制定稿',
};
function defaultActionForPause(pausePoint: CompletedIntervention['pausePoint']): string {
switch (pausePoint) {
case 'draft_confirm':
return 'to_editor';
case 'review_confirm':
return 'accept_review';
case 'section_help':
return 'section_help';
default:
return 'confirm';
}
}
const PAUSE_ICONS: Record<string, React.ElementType> = {
material_confirm: Search,
outline_confirm: PenLine,
draft_confirm: FileText,
review_confirm: ClipboardEdit,
};
function classifyResumeEvent(message: string): Pick<CompletedIntervention, 'pausePoint' | 'action'> | null {
const m = message || '';
if (m.includes('确认素材')) return { pausePoint: 'material_confirm', action: 'confirm' };
if (m.includes('匹配技能检索') || m.includes('重新检索') || m.includes('重新搜索')) {
return { pausePoint: 'material_confirm', action: 're_search' };
}
if (m.includes('确认大纲')) return { pausePoint: 'outline_confirm', action: 'confirm' };
if (m.includes('重新规划大纲')) return { pausePoint: 'outline_confirm', action: 're_outline' };
if (m.includes('提交编辑审核')) return { pausePoint: 'draft_confirm', action: 'to_editor' };
if (m.includes('强制定稿')) return { pausePoint: 'review_confirm', action: 'force_finalize' };
if (m.includes('确认定稿')) return { pausePoint: 'draft_confirm', action: 'finalize' };
if (m.includes('按用户意见重写')) return { pausePoint: 'draft_confirm', action: 'user_revise' };
if (m.includes('按编辑意见重写')) return { pausePoint: 'review_confirm', action: 'accept_review' };
if (m.includes('补充检索素材')) return { pausePoint: 'section_help', action: 'supplement' };
if (m.includes('继续写作')) return { pausePoint: 'section_help', action: 'loose' };
return null;
}
function InterventionTimelineItem({ record, isLast }: { record: CompletedInterventionRecord; isLast: boolean }) {
const Icon = PAUSE_ICONS[record.pausePoint] ?? CheckCircle2;
const ts = record.timestamp
? new Date(record.timestamp).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: '';
function actionFromNextResume(
pausePoint: CompletedIntervention['pausePoint'],
events: ProgressEvent[],
startIndex: number,
): string {
for (let i = startIndex; i < events.length; i += 1) {
const event = events[i];
if (event.type === 'await_user') break;
if (event.type !== 'resumed') continue;
const classified = classifyResumeEvent(event.message);
if (classified?.pausePoint === pausePoint) return classified.action;
break;
}
return defaultActionForPause(pausePoint);
}
return (
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="w-3 h-3 text-primary" />
</div>
{!isLast && <div className="w-px flex-1 bg-border/60 mt-1" />}
</div>
<div className="pb-4 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">
{PAUSE_LABELS[record.pausePoint] ?? record.pausePoint}
</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-primary font-medium">
{ACTION_LABELS[record.action] ?? record.action}
</span>
</div>
{ts && <p className="text-[10px] text-muted-foreground mt-0.5">{ts}</p>}
</div>
</div>
);
function materialsFromEvent(event: ProgressEvent | undefined): CompletedIntervention['materials'] | undefined {
if (!event) return undefined;
if (event.materials?.length) return event.materials;
const raw = event.pauseContext?.materials;
return Array.isArray(raw)
? raw.map((item) => normalizeMaterial(item as Record<string, unknown>))
: undefined;
}
function outlineFromEvent(event: ProgressEvent | undefined): CompletedIntervention['outline'] | undefined {
if (!event) return undefined;
if (event.outline) return event.outline;
const outline = normalizeOutline(event.pauseContext?.outline);
return outline ?? undefined;
}
function reviewFromEvent(event: ProgressEvent | undefined): ReviewResult | null | undefined {
if (!event) return undefined;
if (event.reviewResult) return event.reviewResult;
return normalizeReviewResult(event.pauseContext?.reviewResult ?? event.pauseContext?.review_result) ?? undefined;
}
function updateSnapshot(snapshot: TimelineSnapshot, event: ProgressEvent): void {
if (event.type === 'materials_ready' && event.materials) {
snapshot.materials = event.materials;
}
if (event.type === 'outline_ready' && event.outline) {
snapshot.outline = event.outline;
}
if (event.type === 'draft_ready') {
if (event.draftTitle) snapshot.draftTitle = event.draftTitle;
if (event.draftMarkdown) snapshot.draftWordCount = countArticleWords(event.draftMarkdown);
}
if (event.type === 'review_ready' && event.reviewResult) {
snapshot.reviewResult = event.reviewResult;
}
}
function enrichIntervention(
record: CompletedIntervention,
sourceEvent: ProgressEvent | undefined,
snapshot: TimelineSnapshot,
sessionDraftTitle: string | null,
sessionDraftMarkdown: string | null,
sessionReviewResult: ReviewResult | null,
): CompletedIntervention {
const enriched: CompletedIntervention = { ...record };
if (record.pausePoint === 'material_confirm') {
enriched.materials = record.materials?.length
? record.materials
: (materialsFromEvent(sourceEvent) ?? snapshot.materials ?? []);
}
if (record.pausePoint === 'outline_confirm') {
enriched.outline = record.outline ?? outlineFromEvent(sourceEvent) ?? snapshot.outline;
}
if (record.pausePoint === 'draft_confirm') {
enriched.draftTitle =
record.draftTitle ??
sourceEvent?.draftTitle ??
snapshot.draftTitle ??
sessionDraftTitle ??
undefined;
enriched.draftWordCount =
record.draftWordCount ??
snapshot.draftWordCount ??
(sessionDraftMarkdown ? countArticleWords(sessionDraftMarkdown) : undefined);
enriched.reviewResult =
record.reviewResult ??
reviewFromEvent(sourceEvent) ??
snapshot.reviewResult ??
sessionReviewResult ??
undefined;
}
if (record.pausePoint === 'review_confirm') {
enriched.reviewResult =
record.reviewResult ??
reviewFromEvent(sourceEvent) ??
snapshot.reviewResult ??
sessionReviewResult ??
undefined;
}
return enriched;
}
function synthesizeInterventions(
events: ProgressEvent[],
sessionDraftTitle: string | null,
sessionDraftMarkdown: string | null,
sessionReviewResult: ReviewResult | null,
): CompletedIntervention[] {
const records: CompletedIntervention[] = [];
const snapshot: TimelineSnapshot = {
draftTitle: sessionDraftTitle ?? undefined,
draftWordCount: sessionDraftMarkdown ? countArticleWords(sessionDraftMarkdown) : undefined,
reviewResult: sessionReviewResult,
};
const hasAwaitUser = events.some((event) => event.type === 'await_user');
for (let i = 0; i < events.length; i += 1) {
const event = events[i];
if (event.type === 'await_user') {
const pausePoint = asPausePoint(event.pausePoint);
if (pausePoint) {
const record: CompletedIntervention = {
pausePoint,
action: actionFromNextResume(pausePoint, events, i + 1),
};
records.push(
enrichIntervention(
record,
event,
snapshot,
sessionDraftTitle,
sessionDraftMarkdown,
sessionReviewResult,
),
);
}
} else if (!hasAwaitUser && event.type === 'resumed') {
const classified = classifyResumeEvent(event.message);
if (classified) {
records.push(
enrichIntervention(
classified,
event,
snapshot,
sessionDraftTitle,
sessionDraftMarkdown,
sessionReviewResult,
),
);
}
}
updateSnapshot(snapshot, event);
}
return records;
}
function mergeIntervention(primary: CompletedIntervention, fallback: CompletedIntervention): CompletedIntervention {
const defaultAction = defaultActionForPause(primary.pausePoint);
const action = primary.action === defaultAction && fallback.action
? fallback.action
: (primary.action || fallback.action);
return {
pausePoint: primary.pausePoint,
action,
userInput: primary.userInput ?? fallback.userInput,
materials: primary.materials?.length ? primary.materials : fallback.materials,
outline: primary.outline ?? fallback.outline,
draftTitle: primary.draftTitle ?? fallback.draftTitle,
draftWordCount: primary.draftWordCount ?? fallback.draftWordCount,
reviewResult: primary.reviewResult ?? fallback.reviewResult,
};
}
function mergeInterventionLists(
synthesized: CompletedIntervention[],
transcriptRecords: CompletedIntervention[],
sessionRecords: CompletedIntervention[],
): CompletedIntervention[] {
const max = Math.max(synthesized.length, transcriptRecords.length, sessionRecords.length);
const merged: CompletedIntervention[] = [];
for (let i = 0; i < max; i += 1) {
const anchor = synthesized[i] ?? transcriptRecords[i] ?? sessionRecords[i];
if (!anchor) continue;
let record = anchor;
const candidates = [transcriptRecords[i], sessionRecords[i]];
for (const candidate of candidates) {
if (!candidate || candidate.pausePoint !== record.pausePoint) continue;
record = mergeIntervention(record, candidate);
}
merged.push(record);
}
return merged;
}
function eventFromIntervention(record: CompletedIntervention, sessionId: string): ProgressEvent {
return {
type: 'await_user',
sessionId,
message: PAUSE_MESSAGES[record.pausePoint],
pausePoint: record.pausePoint,
materials: record.materials,
outline: record.outline,
reviewResult: record.reviewResult,
draftTitle: record.draftTitle,
};
}
function ensureInterventionAnchors(
events: ProgressEvent[],
records: CompletedIntervention[],
sessionId: string,
): ProgressEvent[] {
if (events.some((event) => event.type === 'await_user')) return events;
if (records.length === 0) return events;
if (events.length === 0) return records.map((record) => eventFromIntervention(record, sessionId));
const nextEvents: ProgressEvent[] = [];
let recordIndex = 0;
for (const event of events) {
if (event.type === 'resumed' && recordIndex < records.length) {
nextEvents.push(eventFromIntervention(records[recordIndex], sessionId));
recordIndex += 1;
}
nextEvents.push(event);
}
while (recordIndex < records.length) {
nextEvents.push(eventFromIntervention(records[recordIndex], sessionId));
recordIndex += 1;
}
return nextEvents;
}
interface AIWritingHistoryViewProps {
@@ -90,26 +372,42 @@ interface AIWritingHistoryViewProps {
}
export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
const interventions = session.completed_interventions ?? [];
const transcript = session.transcript;
// 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致
const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0;
// 归一化 transcript 事件:后台挂起跑出的事件是 raw(snake_case),前端实时存的是
// camelCase——统一过一遍 normalizeProgressDict(幂等),两种来源回显都正确一致。
const sessionReviewResult = useMemo(
() => normalizeReviewResult(session.review_result),
[session.review_result],
);
const normalizedEvents = useMemo(
() => (transcript?.progressEvents ?? []).map(
(e) => normalizeProgressDict(e as unknown as Record<string, unknown>, session.id),
(event) => normalizeProgressDict(event as unknown as Record<string, unknown>, session.id),
),
[transcript, session.id],
);
// 干预记录同样归一化(后台重建的素材/大纲/审核数据为 snake_case;前端实时记录已是
// camelCase,归一化幂等),保证素材列表/大纲/编辑审核卡片正确渲染。
const normalizedInterventions = useMemo(
() => (transcript?.completedInterventions ?? []).map(
(rec) => normalizeCompletedIntervention(rec as unknown as Record<string, unknown>),
),
const transcriptInterventions = useMemo(
() => normalizeInterventionList(transcript?.completedInterventions),
[transcript],
);
const sessionInterventions = useMemo(
() => normalizeInterventionList(session.completed_interventions),
[session.completed_interventions],
);
const synthesizedInterventions = useMemo(
() => synthesizeInterventions(
normalizedEvents,
session.draft_title,
session.draft_markdown,
sessionReviewResult,
),
[normalizedEvents, session.draft_title, session.draft_markdown, sessionReviewResult],
);
const completedInterventions = useMemo(
() => mergeInterventionLists(synthesizedInterventions, transcriptInterventions, sessionInterventions),
[synthesizedInterventions, transcriptInterventions, sessionInterventions],
);
const timelineEvents = useMemo(
() => ensureInterventionAnchors(normalizedEvents, completedInterventions, session.id),
[normalizedEvents, completedInterventions, session.id],
);
const createdAt = session.created_at
? new Date(session.created_at).toLocaleString('zh-CN', {
year: 'numeric', month: 'short', day: 'numeric',
@@ -119,7 +417,6 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
return (
<div className="flex-1 overflow-y-auto">
{/* 元信息 */}
<div className="px-4 pt-3">
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1">
<p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p>
@@ -134,39 +431,15 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
</div>
</div>
{hasTranscript ? (
/* 完整对话时间线回显 */
{timelineEvents.length > 0 ? (
<AIWritingTimeline
events={normalizedEvents}
completedInterventions={normalizedInterventions}
events={timelineEvents}
completedInterventions={completedInterventions}
currentPause={null}
/>
) : (
/* 旧会话无时间线快照时,回退到精简阶段列表 */
<div className="px-4 py-3 space-y-4">
{interventions.length > 0 ? (
<div>
<p className="text-[11px] font-medium text-muted-foreground mb-2">写作阶段</p>
<div>
{interventions.map((rec, i) => (
<InterventionTimelineItem
key={i}
record={rec}
isLast={i === interventions.length - 1}
/>
))}
</div>
</div>
) : (
<div className="text-center py-4 text-xs text-muted-foreground">无阶段记录</div>
)}
{session.status === 'done' && (
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
<Star className="w-3.5 h-3.5" />
<span>写作完成</span>
</div>
)}
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
暂无阶段记录
</div>
)}
</div>
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, CloudUpload, PanelRightClose, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { ChevronsDown, CloudUpload, PanelRightClose, Pause, Play, Plus, RotateCcw, Settings2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -21,26 +21,18 @@ import {
adminCleanupAIWritingSessions,
getAIWritingSession,
suspendAIWritingToBackground,
suspendAIWritingToBackgroundKeepalive,
} from '../../api/ai-writing-sessions';
/**
* 暂停 / 继续功能开关(默认 false = 关)。
* 暂停 / 继续功能开关(现已开启)。
*
* 历史背景:legacy 路径下,为了让用户点"停止"后还能"继续",引入了 per-worker
* 内存 dict(``_event_queues`` / ``_session_pending_pause``)。多 worker 部署下
* 这些 dict 互相看不见 → SSE 重连失败 / "会话丢失" → 触发了整个 ai-writing-优化.md
* 的迁移计划。
*
* 当前 LangGraph Server 路径下架构已无 per-worker 状态,但 ``stream.stop()`` 仅
* 取消客户端 SSE 订阅,**不会真正暂停后端 run**(LLM 继续烧 token);``resume``
* 也无法重新订阅同一个 run。这是个产品语义没想清楚的功能,先关掉。
*
* 后期想清楚怎么做"真·暂停"(``client.runs.cancel(run_id, action='interrupt')``
* 保留 checkpoint)+ "真·继续"(``client.runs.joinStream`` 续上原 run)时,
* 把这个 flag 翻成 true 即可,UI / hook 全套代码都已就位。
* 真·暂停:``pauseWriting`` → ``stream.stop()`` 取消当前 run(LangGraph 已完成的阶段
* 已落 checkpoint,不丢)。真·继续:``resumeWriting`` → 带空 ``command:{}`` 重新 submit,
* 后端 worker 据此让 Pregel 从最近 checkpoint 续跑挂起的当前阶段节点(重跑当前未完成阶段,
* 已完成阶段不重来;见 runtime/runs/worker.py)。刷新页面 = 重置(不自动重连)。
*/
const ENABLE_PAUSE_RESUME_UI = false;
const ENABLE_PAUSE_RESUME_UI = true;
const RESUMABLE_HISTORY_STATUSES = new Set(['in_progress', 'draft_ready', 'review_ready']);
import { countArticleWords } from '../../utils/wordCount';
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
@@ -89,7 +81,7 @@ export function AIWritingPanel({
} = {}) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, continueWriting, reset } = useAIWriting();
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
const writingForm = useWritingFormOptional();
const { sessions, loading: sessionsLoading, refresh, rename, remove, markBackground, clearBackground } = useAIWritingSessions();
@@ -207,6 +199,15 @@ export function AIWritingPanel({
reset();
}, [closeHistory, reset]);
const canRestoreHistory =
isHistoryMode && !!historySession && RESUMABLE_HISTORY_STATUSES.has(historySession.status);
const handleRestoreHistory = useCallback(() => {
if (!historySession || !RESUMABLE_HISTORY_STATUSES.has(historySession.status)) return;
closeHistory();
continueWriting(historySession.id);
}, [historySession, closeHistory, continueWriting]);
// ── 后台挂起:把当前写作转后台,后端自动驱动到定稿(离页/刷新/切号不打断) ──
// 与「多智能体会商」一致:点击后**立刻**回到初始页面,挂起请求 fire-and-forget;
// 历史下拉靠乐观标记马上显示「挂起中」,不等后端确认。
@@ -234,26 +235,10 @@ export function AIWritingPanel({
})();
}, [state.sessionId, reset, refresh, markBackground, clearBackground]);
// 离页 / 刷新 / 关标签页时,若写作仍在进行中则自动转后台(与圆桌「自动挂起」一致)。
// 用 ref 持有最新的「是否在跑 + 会话 id」,让卸载/卸载前回调读到即时值。
const suspendRef = useRef<{ running: boolean; sid: string | null }>({ running: false, sid: null });
useEffect(() => {
suspendRef.current = { running: isRunning, sid: state.sessionId ?? null };
}, [isRunning, state.sessionId]);
useEffect(() => {
const onBeforeUnload = () => {
const { running, sid } = suspendRef.current;
if (running && sid) suspendAIWritingToBackgroundKeepalive(sid);
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
// 组件卸载(路由离开本页 / 切换工作区标签):写作未完成则自动转后台。
const { running, sid } = suspendRef.current;
if (running && sid) suspendAIWritingToBackgroundKeepalive(sid);
};
}, []);
// 离页 / 刷新 = 重置(不自动重连、不自动转后台):刷新会断开 SSE,后端默认
// on_disconnect=cancel 取消该 run,页面重新挂载回到 idle。需要"后台自动跑完"
// (自动确认素材/大纲/审核到定稿、离页不中断)可手动点「后台挂起」。
// 页内的「暂停/继续」从 checkpoint 续跑当前阶段,不受刷新重置影响(刷新即新开)。
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
@@ -284,6 +269,16 @@ export function AIWritingPanel({
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
}, []);
// 路径4:左侧研究栏「历史记录」点「挂起中」记录 → 打开进度流程图弹窗(与下拉历史一致)。
useEffect(() => {
function onShowProgress(e: Event) {
const session = (e as CustomEvent<AIWritingSessionSummary>).detail;
if (session) setProgressSession(session);
}
window.addEventListener('ai-writing:show-progress', onShowProgress);
return () => window.removeEventListener('ai-writing:show-progress', onShowProgress);
}, []);
// 路径3:URL ?session=xxx 直接定位到指定会话
useEffect(() => {
const sessionId = searchParams.get('session');
@@ -322,19 +317,33 @@ export function AIWritingPanel({
)}
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span
className={cn(
'text-sm font-semibold shrink-0 min-w-0 truncate',
isHistoryMode && 'flex-1 mr-2',
!isIdle && !isHistoryMode && 'cursor-pointer hover:text-primary transition-colors',
<div className="flex min-w-0 flex-1 items-center gap-2">
{canRestoreHistory && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
onClick={handleRestoreHistory}
title="恢复写作:跳过已完成阶段,从未完成阶段重新开始"
>
<Play className="size-3 fill-current" />
恢复
</Button>
)}
onClick={() => { if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }}
title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
>
{isHistoryMode
? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
: 'AI 智能写作'}
</span>
<span
className={cn(
'text-sm font-semibold shrink-0 min-w-0 truncate',
isHistoryMode && 'flex-1 mr-2',
!isIdle && !isHistoryMode && 'cursor-pointer hover:text-primary transition-colors',
)}
onClick={() => { if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }}
title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
>
{isHistoryMode
? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
: 'AI 智能写作'}
</span>
</div>
<div className="flex items-center gap-1.5">
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
<Button
@@ -374,8 +383,8 @@ export function AIWritingPanel({
后台挂起
</Button>
)}
{/* ⏸️/▶️ "停止 / 继续" 由 ENABLE_PAUSE_RESUME_UI 控制(默认 false 隐藏)。
后期把 flag 翻成 true 即可恢复,UI / hook 全套代码都已就位。 */}
{/* ⏸️/▶️ "暂停 / 继续":暂停=stream.stop() 取消当前 run(已完成阶段已落 checkpoint);
继续=从 checkpoint 重新驱动 graph 续跑(重跑当前阶段,已完成阶段不丢)。 */}
{ENABLE_PAUSE_RESUME_UI && isRunning && !state.isPaused && (
<Button
variant="ghost"
@@ -383,8 +392,8 @@ export function AIWritingPanel({
className="h-7 px-2 text-xs gap-1 animate-breathe-stop text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={pauseWriting}
>
<Square className="size-3 fill-current" />
停止
<Pause className="size-3 fill-current" />
暂停
</Button>
)}
{ENABLE_PAUSE_RESUME_UI && state.isPaused && (
@@ -8,7 +8,6 @@ import { buildIntranetSourceUrl } from '@/core/config';
import type { AIMaterial, CompletedIntervention } from '../../types/ai-writing';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
import { ScoreBar } from './ScoreBar';
import { CollapseTransition } from './CollapseTransition';
interface Props {
@@ -282,14 +281,8 @@ function ReviewCompleted({ record, expanded, onToggle }: { record: CompletedInte
</button>
<CollapseTransition open={expanded && !!record.reviewResult}>
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
{record.reviewResult && (
<>
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
</>
)}
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
{record.reviewResult && <ReviewCard review={record.reviewResult} />}
</div>
</CollapseTransition>
</div>
@@ -3,20 +3,26 @@
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, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
@@ -33,9 +39,13 @@ 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;
@@ -86,14 +96,146 @@ function getTemplateVisual(tpl: ResearchTemplate, index: number): TemplateVisual
return TEMPLATE_VISUALS[index % TEMPLATE_VISUALS.length];
}
// 历史记录状态药丸样式(对齐多智能体会商的历史记录观感)。
// 注:`in_progress` 不再转圈显示「进行中」——刷新=重置后这些会话已无任何活进程,
// 转圈会误导用户以为还在跑;统一显示为中性、不动的「未完成」。真正还在跑的是 `background`
// (后台挂起,有 AIWritingJobExecutor 驱动),保留「挂起中」转圈。
const WRITING_STATUS_PILL: Record<string, { label: string; className: string; spin?: boolean; dot?: boolean }> = {
in_progress: { label: "未完成", className: "text-slate-400" },
background: { label: "挂起中", className: "text-sky-500", spin: true },
draft_ready: { label: "草稿", className: "text-amber-500" },
review_ready: { label: "审核", className: "text-purple-500", dot: true },
done: { label: "完成", className: "text-emerald-500" },
error: { label: "出错", className: "text-rose-500" },
};
/** 单条历史记录项 —— 样式参考多智能体会商的 HistoryDropdown 列表项(圆角卡片 + 状态药丸 + 操作按钮)。 */
function WritingHistoryItem({
session,
isCurrent,
onSelect,
onShowProgress,
onDelete,
onRename,
}: {
session: AIWritingSessionSummary;
isCurrent: boolean;
onSelect: () => void;
onShowProgress?: () => void;
onDelete: () => void;
onRename: (title: string) => void;
}) {
const [renaming, setRenaming] = useState(false);
const [value, setValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
const isBackground = session.status === "background";
// Row/title click only opens the history view; restore starts from the detail header.
const primary = isBackground && onShowProgress ? onShowProgress : onSelect;
const primaryTitle = isBackground ? "查看进度" : "打开历史";
const pill = WRITING_STATUS_PILL[session.status] ?? { label: session.status, className: "text-slate-400" };
useEffect(() => {
if (renaming) inputRef.current?.focus();
}, [renaming]);
const commit = () => {
const t = value.trim();
if (t && t !== session.title) onRename(t);
setRenaming(false);
};
return (
<li
className="px-3 py-2.5 transition-colors hover:bg-slate-50"
style={isCurrent ? { backgroundColor: "#ecfdf5" } : undefined}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
{renaming ? (
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setValue(session.title);
setRenaming(false);
}
}}
onBlur={commit}
className="w-full rounded border border-slate-300 px-2 py-1 text-[11.5px] font-semibold text-slate-800 outline-none focus:border-primary"
/>
) : (
<button
type="button"
onClick={primary}
className="block w-full text-left"
title={primaryTitle}
>
<span className="line-clamp-2 text-[11.5px] font-semibold leading-snug text-slate-800">
{session.title}
</span>
<span className="mt-0.5 flex items-center gap-1 font-mono text-[10px] text-slate-400">
<span className={cn("inline-flex items-center gap-1 font-semibold", pill.className)}>
{pill.spin && <Loader2 className="size-3 animate-spin" />}
{pill.dot && <span className="inline-block size-1.5 animate-pulse rounded-full bg-current" />}
{pill.label}
</span>
<span className="text-slate-300">·</span>
{formatRelative(session.updated_at)}
</span>
</button>
)}
</div>
{!renaming && (
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={isBackground && onShowProgress ? onShowProgress : onSelect}
title={isBackground ? "查看进度" : "只读查看"}
className="rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-primary"
>
<Eye className="size-3.5" />
</button>
<button
type="button"
onClick={() => {
setValue(session.title);
setRenaming(true);
}}
title="重命名"
className="rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-primary"
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
onClick={onDelete}
title="删除"
className="rounded p-1 text-slate-500 transition hover:bg-rose-50 hover:text-rose-600"
>
<Trash2 className="size-3.5" />
</button>
</div>
)}
</div>
</li>
);
}
export function ResearchSidebar({
viewingNotebookId,
onOpenNotebook,
onOpenAllSpaces,
onCollapse,
}: {
viewingNotebookId?: number | null;
onOpenNotebook: (notebookId: number) => void;
onOpenAllSpaces: () => void;
/** 收起侧栏(由页面承载收起态)。 */
onCollapse?: () => void;
}) {
const navigate = useNavigate();
const { templates, refresh } = useResearchTemplates();
@@ -116,6 +258,31 @@ export function ResearchSidebar({
} = useWritingForm();
const queryClient = useQueryClient();
// 「历史记录」列表(左下角,样式参考多智能体会商的历史记录):自带一份会话列表,
// 点击打开「历史回看」(openHistory,全局共享上下文,打开后本侧栏随写作进入历史态而隐藏)。
const {
sessions,
loading: sessionsLoading,
refresh: refreshSessions,
rename: renameSession,
remove: removeSession,
} = useAIWritingSessions();
const { openHistory, historySession } = useAIWritingHistory();
// 进入/返回写作配置页时刷新一次历史(本侧栏仅在 idle 态挂载,故等同每次回到表单刷新)。
useEffect(() => {
void refreshSessions();
}, [refreshSessions]);
const handleSelectHistory = (session: AIWritingSessionSummary) => {
void openHistory(session);
};
// 「挂起中」记录:交给右侧面板打开进度流程图弹窗(沿用面板已有的 window 事件解耦模式,
// 与 ai-writing:open-session 一致),本侧栏不直接持有弹窗状态。
const handleShowProgress = (session: AIWritingSessionSummary) => {
window.dispatchEvent(new CustomEvent("ai-writing:show-progress", { detail: session }));
};
const [templateSearch, setTemplateSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [name, setName] = useState("");
@@ -191,6 +358,20 @@ export function ResearchSidebar({
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 && (
<div className="flex shrink-0 items-center justify-between border-b border-slate-100 px-3 py-1.5">
<span className="text-[11px] font-semibold tracking-wide text-slate-400">课题研究</span>
<button
type="button"
onClick={onCollapse}
title="收起侧栏"
aria-label="收起侧栏"
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700"
>
<PanelLeftClose className="size-4" />
</button>
</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">
@@ -276,7 +457,7 @@ export function ResearchSidebar({
)}
</section>
<section className="flex min-h-0 flex-1 flex-col border-t border-slate-100 pt-5">
<section className="flex flex-col border-t border-slate-100 pt-5">
<div className="mb-3 flex items-center justify-between px-2">
<h2 className="text-[15px] font-bold tracking-wide text-slate-400">
笔记本
@@ -373,6 +554,52 @@ export function ResearchSidebar({
</button>
</div>
</section>
{/* 「历史记录」列表(样式参考多智能体会商):跟随侧栏自然向下滚动、不固定在底部。
点击打开历史回看(显示到右侧区域),✏️/双击改名,🗑删除,「挂起中」点击看进度。 */}
<section className="border-t border-slate-100 pt-5">
<div className="mb-3 flex items-center justify-between px-2">
<h2 className="flex items-center gap-1.5 text-[15px] font-bold tracking-wide text-slate-400">
<History className="size-3.5 text-slate-400" />
历史记录
<span className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-400">
{sessions.length}
</span>
</h2>
<button
type="button"
onClick={() => void refreshSessions()}
title="刷新历史记录"
aria-label="刷新历史记录"
className={cn(
"rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-600",
sessionsLoading && "animate-spin",
)}
>
<RefreshCw className="size-3.5" />
</button>
</div>
{sessions.length === 0 ? (
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
{sessionsLoading ? "加载中…" : "暂无历史记录,开始写作后自动保存到这里"}
</p>
) : (
<ul className="divide-y divide-slate-100 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
{sessions.map((s) => (
<WritingHistoryItem
key={s.id}
session={s}
isCurrent={s.id === (historySession?.id ?? null)}
onSelect={() => handleSelectHistory(s)}
onShowProgress={() => handleShowProgress(s)}
onDelete={() => removeSession(s.id)}
onRename={(title) => renameSession(s.id, title)}
/>
))}
</ul>
)}
</section>
</div>
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
@@ -40,6 +40,7 @@ interface LastAction {
action: RewriteAction;
tone?: RewriteTone;
customInstruction?: string;
referenceText?: string;
}
export function TextArtifactRenderer({ content, isStreaming, readOnly = false, onSaveDraft }: TextArtifactRendererProps) {
@@ -137,9 +138,9 @@ export function TextArtifactRenderer({ content, isStreaming, readOnly = false, o
}, [reset, editor]);
const handleAction = useCallback(
(action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
lastActionRef.current = { action, tone, customInstruction };
rewrite(action, tone, customInstruction);
(action: RewriteAction, tone?: RewriteTone, customInstruction?: string, referenceText?: string) => {
lastActionRef.current = { action, tone, customInstruction, referenceText };
rewrite(action, tone, customInstruction, referenceText);
setPhase("preview");
},
[rewrite],
@@ -147,15 +148,15 @@ export function TextArtifactRenderer({ content, isStreaming, readOnly = false, o
const handleRetry = useCallback(() => {
if (!lastActionRef.current) return;
const { action, tone, customInstruction } = lastActionRef.current;
rewrite(action, tone, customInstruction);
const { action, tone, customInstruction, referenceText } = lastActionRef.current;
rewrite(action, tone, customInstruction, referenceText);
}, [rewrite]);
const handleFollowUp = useCallback(
(instruction: string) => {
if (!lastActionRef.current) return;
const { action, tone } = lastActionRef.current;
rewriteFollowUp(instruction, action, tone);
const { action, tone, referenceText } = lastActionRef.current;
rewriteFollowUp(instruction, action, tone, referenceText);
},
[rewriteFollowUp],
);
@@ -6,11 +6,13 @@ import {
Expand,
Shrink,
Mic2,
BookCopy,
AlertCircle,
RefreshCw,
ChevronRight,
X,
Square,
Send,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
@@ -44,6 +46,7 @@ const ACTION_ROWS: {
{ action: "expand", label: "扩写", icon: Expand },
{ action: "shorten", label: "缩写", icon: Shrink },
{ action: "tone", label: "改语气", icon: Mic2 },
{ action: "imitate", label: "仿写", icon: BookCopy },
];
// ── Main component ─────────────────────────────────────────────────────────────
@@ -52,7 +55,7 @@ interface WritingAiMenuProps {
pos: MenuPos;
status: RewriteStatus;
error: string | null;
onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => void;
onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string, referenceText?: string) => void;
onAbort: () => void;
onRetry: () => void;
onClose: () => void;
@@ -68,8 +71,11 @@ export function WritingAiMenu({
onClose,
}: WritingAiMenuProps) {
const [toneOpen, setToneOpen] = useState(false);
const [imitateDialogOpen, setImitateDialogOpen] = useState(false);
const [referenceText, setReferenceText] = useState("");
const [instruction, setInstruction] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const imitateTextareaRef = useRef<HTMLTextAreaElement>(null);
// autoFocus is unreliable when the menu opens during a mousedown event sequence;
// explicit focus via setTimeout(0) runs after the browser finishes that sequence.
@@ -78,6 +84,13 @@ export function WritingAiMenu({
return () => clearTimeout(id);
}, []);
// Focus the dialog textarea when the imitate dialog opens.
useEffect(() => {
if (!imitateDialogOpen) return;
const id = setTimeout(() => imitateTextareaRef.current?.focus(), 0);
return () => clearTimeout(id);
}, [imitateDialogOpen]);
const isLoading = status === "loading";
const isError = status === "error";
@@ -110,6 +123,13 @@ export function WritingAiMenu({
onAction(action, tone, instruction.trim() || undefined);
};
const fireImitate = () => {
const ref = referenceText.trim();
if (!ref) return;
setImitateDialogOpen(false);
onAction("imitate", undefined, instruction.trim() || undefined, ref);
};
const handleInputEnter = () => {
const trimmed = instruction.trim();
onAction("polish", undefined, trimmed || undefined);
@@ -230,7 +250,9 @@ export function WritingAiMenu({
<div className="p-1 space-y-0.5">
{ACTION_ROWS.map(({ action, label, icon: Icon }) => {
const isTone = action === "tone";
const isTonesOpen = isTone && toneOpen;
const isImitate = action === "imitate";
const hasSub = isTone;
const isExpanded = isTone && toneOpen;
return (
<div key={action}>
<button
@@ -238,6 +260,9 @@ export function WritingAiMenu({
onClick={() => {
if (isTone) {
setToneOpen((v) => !v);
} else if (isImitate) {
setToneOpen(false);
setImitateDialogOpen(true);
} else {
fireAction(action);
}
@@ -245,23 +270,23 @@ export function WritingAiMenu({
className={cn(
"flex w-full items-center gap-2 px-2.5 py-1.5 text-xs rounded-md transition-colors text-left",
"hover:bg-accent hover:text-accent-foreground",
isTonesOpen && "bg-accent text-accent-foreground",
isExpanded && "bg-accent text-accent-foreground",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1">{label}</span>
{isTone && (
{hasSub && (
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
isTonesOpen && "rotate-90",
isExpanded && "rotate-90",
)}
/>
)}
</button>
{/* Tone sub-list */}
{isTonesOpen && (
{isTone && toneOpen && (
<div className="ml-5 border-l pl-2 my-0.5 space-y-0.5">
{TONE_OPTIONS.map(({ value, label: toneLabel }) => (
<button
@@ -282,6 +307,85 @@ export function WritingAiMenu({
</>
)}
</div>
{/* ── Imitate dialog:弹窗内多行输入范文,回车 / 发送即仿写 ───────────── */}
{imitateDialogOpen && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/40 animate-in fade-in duration-100"
onMouseDown={() => setImitateDialogOpen(false)}
/>
<div
className="relative w-full max-w-md rounded-xl border bg-popover shadow-2xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<span className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<BookCopy className="h-4 w-4 text-amber-500" />
仿写
</span>
<button
type="button"
onClick={() => setImitateDialogOpen(false)}
className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="p-4 space-y-3">
<textarea
ref={imitateTextareaRef}
value={referenceText}
onChange={(e) => setReferenceText(e.target.value)}
onKeyDown={(e) => {
// Enter 提交,Shift+Enter 换行
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
fireImitate();
} else if (e.key === "Escape") {
e.preventDefault();
setImitateDialogOpen(false);
}
}}
rows={8}
placeholder="粘贴范文,AI 将模仿其文风、句式与语气改写所选文字…(回车提交,Shift+Enter 换行)"
className={cn(
"w-full resize-none text-sm rounded-md px-3 py-2 outline-none transition-colors leading-relaxed",
"bg-muted/50 border border-input focus:border-primary/60",
"placeholder:text-muted-foreground/50",
)}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/20">
<button
type="button"
onClick={() => setImitateDialogOpen(false)}
className="px-3 py-1.5 text-xs font-medium rounded-md border border-input hover:bg-accent hover:text-accent-foreground transition-colors"
>
取消
</button>
<button
type="button"
onClick={fireImitate}
disabled={!referenceText.trim()}
className={cn(
"flex items-center justify-center gap-1.5 px-4 py-1.5 text-xs font-medium rounded-md transition-colors",
"bg-primary text-primary-foreground hover:bg-primary/90",
"disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
<Send className="h-3.5 w-3.5" />
发送
</button>
</div>
</div>
</div>
)}
</>,
document.body,
);
@@ -14,6 +14,7 @@ const ACTION_LABEL: Record<RewriteAction, string> = {
expand: "扩写",
shorten: "缩写",
tone: "改语气",
imitate: "仿写",
};
const TONE_LABEL: Record<RewriteTone, string> = {
@@ -26,6 +26,9 @@ import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countArticleWords } from '../utils/wordCount';
import {
INITIAL_STATE,
buildAwaitUserEventFromCheckpointPause,
buildCompletedInterventionFromCheckpointPause,
buildRehydrateSnapshot,
customToProgressEvent,
interruptToEvent,
nodeUpdatesToEvents,
@@ -33,6 +36,8 @@ import {
type Action,
type AIWritingValues,
} from '../hooks/useAIWritingStream';
const WRITING_STREAM_MODE = ['values', 'updates', 'custom', 'messages-tuple'] as const;
import type {
AIWritingRequest,
AIWritingState,
@@ -93,12 +98,105 @@ type AIWritingStreamApi = Pick<
'submit' | 'stop' | 'interrupt'
>;
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? value as Record<string, unknown> : {};
}
function pausePointFromStatus(status: string): string | null {
return status.startsWith('awaiting_') ? status.slice('awaiting_'.length) : null;
}
function defaultResumePayloadForPause(
pausePoint: string | null | undefined,
interruptValue: unknown,
values: AIWritingValues,
): Record<string, unknown> {
const intr = asRecord(interruptValue);
const valueRecord = asRecord(values);
if (pausePoint === 'material_confirm') {
return { action: 'confirm' };
}
if (pausePoint === 'outline_confirm') {
return { action: 'confirm' };
}
if (pausePoint === 'section_help') {
const blocked = Array.isArray(intr.blocked_sections)
? intr.blocked_sections
: (Array.isArray(valueRecord.blocked_sections) ? valueRecord.blocked_sections : []);
const decisions = blocked
.map((b) => asRecord(b))
.map((b) => String(b.section_title ?? b.sectionTitle ?? ''))
.filter(Boolean)
.map((sectionTitle) => ({ sectionTitle, decision: 'loose' }));
return { action: 'section_help', sectionDecisions: decisions };
}
if (pausePoint === 'draft_confirm') {
const review = asRecord(valueRecord.review_result);
const hasReview = Object.keys(review).length > 0;
const revisionCount = Number(valueRecord.revision_count ?? 0);
const maxRevisions = Number(valueRecord.max_revisions ?? 3);
if (!hasReview) return { action: 'to_editor' };
if (review.verdict === 'pass') return { action: 'finalize' };
if (revisionCount >= maxRevisions) return { action: 'finalize' };
return { action: 'to_editor' };
}
if (pausePoint === 'review_confirm') {
return { action: 'accept_review' };
}
return { action: 'confirm' };
}
function nextStatusAfterResumePayload(
pausePoint: string | null | undefined,
payload: Record<string, unknown>,
): WritingStatus {
const action = String(payload.action ?? 'confirm');
if (pausePoint === 'material_confirm') {
return (action === 're_search' || action === 'enable_skill_search')
? 'researching'
: 'writing_outline';
}
if (pausePoint === 'outline_confirm') {
return action === 're_outline' ? 'writing_outline' : 'writing_draft';
}
if (pausePoint === 'draft_confirm') {
if (action === 'finalize') return 'done';
if (action === 'to_editor') return 'reviewing';
return 'revising';
}
if (pausePoint === 'review_confirm') {
return action === 'force_finalize' ? 'done' : 'revising';
}
if (pausePoint === 'section_help') {
const decisions = Array.isArray(payload.sectionDecisions) ? payload.sectionDecisions : [];
return decisions.some((d) => asRecord(d).decision === 'supplement')
? 'researching'
: 'writing_draft';
}
return 'researching';
}
export type AIWritingApi = {
state: AIWritingState;
startWriting: (request: AIWritingRequest) => Promise<void>;
submitIntervention: (payload: UserInterventionPayload) => Promise<void>;
pauseWriting: () => void;
resumeWriting: () => void;
/** Restore an unfinished history session: rehydrate completed stages from checkpoint,
* auto-advance completed awaiting_* stages, and re-run the active unfinished stage. */
continueWriting: (sessionId: string) => void;
reset: () => void;
};
@@ -131,12 +229,15 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
sessionIdRef,
streamRef,
seenInterruptRef,
continueTargetRef,
}: {
threadId: string | undefined;
dispatchRef: RefObject<Dispatch<Action>>;
sessionIdRef: RefObject<string | null>;
streamRef: MutableRefObject<AIWritingStreamApi | null>;
seenInterruptRef: MutableRefObject<string | null>;
/** Non-empty while an explicit history restore is waiting for checkpoint values. */
continueTargetRef: MutableRefObject<string | null>;
}) {
const eventBatcherRef = useRef<ReturnType<typeof createProgressEventBatcher> | null>(null);
if (!eventBatcherRef.current) {
@@ -155,7 +256,8 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
client: getAPIClient(),
assistantId: 'ai_writing',
threadId,
reconnectOnMount: true,
// 刷新页面 = 重置:不在挂载时自动重连已有 run(避免刷新后接回旧会话)。
reconnectOnMount: false,
onCreated(meta) {
dispatchRef.current?.({ type: 'set_session', sessionId: meta.thread_id });
},
@@ -230,10 +332,61 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
}
const key = intr.id ?? JSON.stringify(intr.value);
if (seenInterruptRef.current === key) return;
if (continueTargetRef.current && continueTargetRef.current === threadId) {
seenInterruptRef.current = key;
return;
}
seenInterruptRef.current = key;
const ev = interruptToEvent(intr.value, sessionIdRef.current ?? '');
if (ev) emitProgressEvent(ev);
}, [stream.interrupt, emitProgressEvent, seenInterruptRef, sessionIdRef]);
}, [stream.interrupt, emitProgressEvent, seenInterruptRef, sessionIdRef, continueTargetRef, threadId]);
// History restore is explicit: after the history detail "restore" button is clicked,
// rehydrate from checkpoint, auto-advance completed awaiting_* stages with default
// decisions, and re-run the currently active unfinished stage with an empty command.
const rehydratedForRef = useRef<string | null>(null);
useEffect(() => {
const target = continueTargetRef.current;
if (!target || !threadId || target !== threadId) return;
if (rehydratedForRef.current === threadId) return;
const values = stream.values as AIWritingValues | undefined;
if (!values || !values.status) return; // 等 fetchStateHistory 把 checkpoint 拉到
rehydratedForRef.current = threadId;
continueTargetRef.current = null;
const snapshot = buildRehydrateSnapshot(values, threadId);
dispatchRef.current?.({ type: 'rehydrate', snapshot });
const status = String(snapshot.status ?? '');
const checkpointModelName = typeof values.model_name === 'string' ? values.model_name : '';
const submitFromCheckpoint = (command: Record<string, unknown>) => {
void streamRef.current?.submit(undefined, {
command,
streamMode: [...WRITING_STREAM_MODE],
...(checkpointModelName ? { config: { configurable: { model_name: checkpointModelName } } } : {}),
}).catch(() => { /* 流式错误由 onError 反映 */ });
};
if (status.startsWith('awaiting_')) {
const intr = stream.interrupt;
if (intr) seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
const pausePoint = (asRecord(intr?.value).pause_point as string | undefined) ?? pausePointFromStatus(status);
const resumePayload = defaultResumePayloadForPause(pausePoint, intr?.value, values);
const pauseEvent = buildAwaitUserEventFromCheckpointPause(values, threadId, pausePoint, intr?.value);
if (pauseEvent) {
dispatchRef.current?.({ type: 'handle', event: pauseEvent });
}
const record = buildCompletedInterventionFromCheckpointPause(values, pausePoint, resumePayload.action);
if (record) {
dispatchRef.current?.({
type: 'intervention_submitted',
record,
nextStatus: nextStatusAfterResumePayload(pausePoint, resumePayload),
pendingRevisionContext: null,
});
}
submitFromCheckpoint({ resume: resumePayload });
} else {
submitFromCheckpoint({});
}
}, [stream.values, stream.interrupt, threadId, continueTargetRef, dispatchRef, seenInterruptRef, streamRef]);
useEffect(() => () => { eventBatcherRef.current?.cancel(); }, []);
@@ -249,7 +402,11 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
sessionIdRef.current = state.sessionId;
const streamRef = useRef<AIWritingStreamApi | null>(null);
const seenInterruptRef = useRef<string | null>(null);
// Explicit history restore target; the host rehydrates once this thread's checkpoint arrives.
const continueTargetRef = useRef<string | null>(null);
// 刷新页面 = 重置:不持久化、不自动重连。provider 重新挂载即回到 idle 初始态
// (刷新会断开 SSE,后端默认 on_disconnect=cancel 取消该 run,不留孤儿)。
const threadId = state.sessionId ?? undefined;
useEffect(() => {
@@ -306,7 +463,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
} as Partial<AIWritingValues>,
{
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
streamMode: [...WRITING_STREAM_MODE],
},
);
} catch (err) {
@@ -410,7 +567,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
const resumeModelName = state.request?.modelName;
await stream.submit(undefined, {
command: { resume: resumePayload },
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
streamMode: [...WRITING_STREAM_MODE],
...(resumeModelName ? { config: { configurable: { model_name: resumeModelName } } } : {}),
});
} catch (err) {
@@ -426,24 +583,69 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
state.status]);
const pauseWriting = useCallback(() => {
// 真停:取消当前 run(graph 已完成的阶段已落 checkpoint)。继续时从该 checkpoint 续跑。
void streamRef.current?.stop();
dispatch({ type: 'pause' });
}, []);
// 「继续」:清掉当前阶段流式缓冲后,从最近的 checkpoint 恢复 graph 续跑
// (会重跑当前未完成的阶段,已完成阶段不丢)。同时用于断网后 run 已死的兜底恢复。
//
// **关键**:必须带一个 `command`(哪怕是空对象),后端 worker 才会把它翻成
// `langgraph.types.Command` 让 Pregel **从最近 checkpoint 恢复**(与 4 个干预卡续跑同机制);
// 不带 command 时 worker 按 graph_input **起新一轮**=从图入口 intent 重跑(就是之前
// "暂停后继续又从识别意图开始" 的根因,见 runtime/runs/worker.py 注释)。空 Command
// 没有 interrupt 可消费 → Pregel 直接续跑 checkpoint 里挂起的当前阶段节点。
const resumeWriting = useCallback(() => {
dispatch({ type: 'resume_ui' });
}, []);
const stream = streamRef.current;
if (!stream) {
dispatch({ type: 'resume_ui' });
return;
}
dispatch({ type: 'clear_streaming' });
const resumeModelName = state.request?.modelName;
void (async () => {
try {
await stream.submit(undefined, {
command: {},
streamMode: [...WRITING_STREAM_MODE],
...(resumeModelName ? { config: { configurable: { model_name: resumeModelName } } } : {}),
});
} catch (err) {
const message = getStreamErrorMessage(err);
if (isThreadNotAccessibleError(message)) {
notifyThreadAccessChangedAndReload();
return;
}
dispatch({ type: 'set_error', message });
}
})();
}, [state.request]);
// Restore an unfinished history session: make it active, load its checkpoint, then let
// the host skip completed awaiting_* stages or re-run the current unfinished stage.
// 与「刷新=重置」不冲突:这是显式点击触发,不是自动重连。
const continueWriting = useCallback((sessionId: string) => {
if (!sessionId) return;
void streamRef.current?.stop();
setArtifact(null);
seenInterruptRef.current = null;
continueTargetRef.current = sessionId;
dispatch({ type: 'reset' });
dispatch({ type: 'set_session', sessionId });
}, [setArtifact]);
const reset = useCallback(() => {
void streamRef.current?.stop();
setArtifact(null);
seenInterruptRef.current = null;
continueTargetRef.current = null;
dispatch({ type: 'reset' });
}, [setArtifact]);
const value = useMemo(
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }),
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset],
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, continueWriting, reset }),
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, continueWriting, reset],
);
return (
@@ -454,6 +656,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
sessionIdRef={sessionIdRef}
streamRef={streamRef}
seenInterruptRef={seenInterruptRef}
continueTargetRef={continueTargetRef}
/>
{children}
</AIWritingContext.Provider>
@@ -37,8 +37,8 @@ export interface WritingFormValue {
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认开。 */
addReferences: boolean;
setAddReferences: (v: boolean) => void;
materialSource: 'general' | 'notebook';
setMaterialSource: (v: 'general' | 'notebook') => void;
materialSource: 'general' | 'knowledge_base' | 'notebook';
setMaterialSource: (v: 'general' | 'knowledge_base' | 'notebook') => void;
/** 不收集素材,直接用通用知识写作:勾选后跳过素材检索阶段(含素材确认暂停点)。默认关。 */
skipResearch: boolean;
setSkipResearch: (v: boolean) => void;
@@ -75,7 +75,7 @@ export function WritingFormProvider({
initialDocumentIds,
}: {
children: ReactNode;
initialMaterialSource?: 'general' | 'notebook';
initialMaterialSource?: 'general' | 'knowledge_base' | 'notebook';
initialNotebookId?: number | null;
initialDocumentIds?: number[];
}) {
@@ -88,7 +88,7 @@ export function WritingFormProvider({
const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false);
const [addReferences, setAddReferences] = useState(true);
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
const [materialSource, setMaterialSource] = useState<'general' | 'knowledge_base' | 'notebook'>(initialMaterialSource ?? 'general');
const [skipResearch, setSkipResearch] = useState(false);
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
@@ -105,8 +105,10 @@ export function WritingFormProvider({
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
if (payload.writing_mode === 'strict') setStrictMode(true);
else if (payload.writing_mode === 'loose') setStrictMode(false);
// 旧值 skill / knowledge_base 已合并为通用检索 general
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base' || payload.material_source === 'general') {
// knowledge_base=知识库检索(直连内网 ES)保留;旧值 skill 归一为通用检索 general
if (payload.material_source === 'knowledge_base') {
setMaterialSource('knowledge_base');
} else if (payload.material_source === 'skill' || payload.material_source === 'general') {
setMaterialSource('general');
}
if (payload.outline?.trim()) {
@@ -85,6 +85,10 @@ export type Action =
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
| { type: 'pause' }
| { type: 'resume_ui' }
// Restore history: rebuild state from the graph checkpoint values snapshot.
| { type: 'rehydrate'; snapshot: Partial<AIWritingState> }
// 手动「继续」从检查点重跑当前阶段前,清空该阶段的流式缓冲(避免与重跑的新输出粘连)。
| { type: 'clear_streaming' }
| { type: 'reset' }
| { type: 'set_error'; message: string };
@@ -132,6 +136,22 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
case 'resume_ui':
return { ...prev, isPaused: false };
case 'rehydrate':
// Restore history: layer checkpoint snapshot over INITIAL_STATE.
return { ...INITIAL_STATE, ...action.snapshot, isPaused: false };
case 'clear_streaming':
// 「继续」重跑当前阶段:清掉各阶段流式缓冲,重跑的输出从干净状态累积。
return {
...prev,
isPaused: false,
intentStreamingText: '', intentThinkingText: '',
outlineStreamingText: '', outlineThinkingText: '',
streamingDraftSections: [], streamingDraftTitle: '',
streamingReviewItems: [],
skillSelectStreamingText: '', skillSelectThinkingText: '',
};
case 'reset':
return INITIAL_STATE;
@@ -500,6 +520,163 @@ export function normalizeReviewResult(r: unknown): ReviewResult | null {
};
}
const REHYDRATE_PAUSE_MESSAGES: Record<CompletedIntervention['pausePoint'], string> = {
material_confirm: '素材收集专家已完成素材搜集,请确认素材后继续。',
outline_confirm: '作家已完成大纲规划,请确认或编辑大纲后继续。',
draft_confirm: '草稿已完成,请确认或给出修改意见。',
review_confirm: '编辑认为文章需要修改,请决定下一步操作。',
section_help: '部分章节缺少足够素材支撑写作,请决定如何处理。',
};
function normalizePausePoint(value: unknown): CompletedIntervention['pausePoint'] | null {
const pausePoint = String(value ?? '');
switch (pausePoint) {
case 'material_confirm':
case 'outline_confirm':
case 'draft_confirm':
case 'review_confirm':
case 'section_help':
return pausePoint;
default:
return null;
}
}
function classifyResumedMessage(message: string): Pick<CompletedIntervention, 'pausePoint' | 'action'> | null {
const m = message || '';
if (m.includes('确认素材')) return { pausePoint: 'material_confirm', action: 'confirm' };
if (m.includes('匹配技能检索') || m.includes('重新检索') || m.includes('重新搜索')) {
return { pausePoint: 'material_confirm', action: 're_search' };
}
if (m.includes('采用你提供的大纲')) return { pausePoint: 'material_confirm', action: 'confirm' };
if (m.includes('确认大纲')) return { pausePoint: 'outline_confirm', action: 'confirm' };
if (m.includes('重新规划大纲')) return { pausePoint: 'outline_confirm', action: 're_outline' };
if (m.includes('提交编辑审核')) return { pausePoint: 'draft_confirm', action: 'to_editor' };
if (m.includes('强制定稿')) return { pausePoint: 'review_confirm', action: 'force_finalize' };
if (m.includes('确认定稿')) return { pausePoint: 'draft_confirm', action: 'finalize' };
if (m.includes('按用户意见重写')) return { pausePoint: 'draft_confirm', action: 'user_revise' };
if (m.includes('按编辑意见重写')) return { pausePoint: 'review_confirm', action: 'accept_review' };
if (m.includes('补充检索素材')) return { pausePoint: 'section_help', action: 'supplement' };
if (m.includes('继续写作') || m.includes('按你的选择继续写作')) {
return { pausePoint: 'section_help', action: 'loose' };
}
return null;
}
function materialsFromValues(values: AIWritingValues): AIMaterial[] {
const pkg = values.material_package && typeof values.material_package === 'object'
? values.material_package as Record<string, unknown>
: null;
return pkg && Array.isArray(pkg.materials)
? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial)
: [];
}
function draftFromValues(values: AIWritingValues): Record<string, unknown> | null {
return values.current_draft && typeof values.current_draft === 'object'
? values.current_draft as Record<string, unknown>
: null;
}
export function buildCompletedInterventionFromCheckpointPause(
values: AIWritingValues,
pausePointValue: unknown,
actionValue: unknown,
): CompletedIntervention | null {
const pausePoint = normalizePausePoint(pausePointValue);
if (!pausePoint) return null;
const action = String(actionValue || 'confirm');
const record: CompletedIntervention = { pausePoint, action };
if (pausePoint === 'material_confirm') {
record.materials = materialsFromValues(values);
} else if (pausePoint === 'outline_confirm') {
record.outline = normalizeOutline(values.current_outline) ?? undefined;
} else if (pausePoint === 'draft_confirm') {
const draft = draftFromValues(values);
record.draftTitle = String(draft?.title ?? '');
const wordCount = Number(draft?.word_count ?? draft?.wordCount ?? 0);
record.draftWordCount = wordCount > 0
? wordCount
: countArticleWords(String(draft?.full_markdown ?? draft?.fullMarkdown ?? ''));
record.reviewResult = normalizeReviewResult(values.review_result) ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = normalizeReviewResult(values.review_result) ?? undefined;
}
return record;
}
export function buildAwaitUserEventFromCheckpointPause(
values: AIWritingValues,
sessionId: string,
pausePointValue: unknown,
interruptValue?: unknown,
): ProgressEvent | null {
const pausePoint = normalizePausePoint(pausePointValue);
if (!pausePoint) return null;
const intr = interruptValue && typeof interruptValue === 'object'
? interruptValue as Record<string, unknown>
: {};
const draft = draftFromValues(values);
const event: ProgressEvent = {
type: 'await_user',
sessionId,
message: String(intr.message ?? REHYDRATE_PAUSE_MESSAGES[pausePoint]),
pausePoint,
};
if (pausePoint === 'material_confirm') {
event.materials = Array.isArray(intr.materials)
? (intr.materials as Record<string, unknown>[]).map(normalizeMaterial)
: materialsFromValues(values);
} else if (pausePoint === 'outline_confirm') {
event.outline = normalizeOutline(intr.outline ?? values.current_outline) ?? undefined;
} else if (pausePoint === 'draft_confirm') {
event.draftTitle = String(intr.draft_title ?? draft?.title ?? '');
event.revisionCount = Number(intr.revision_count ?? draft?.revision_count ?? draft?.revisionCount ?? 0);
event.reviewResult = normalizeReviewResult(intr.review_result ?? values.review_result) ?? undefined;
} else if (pausePoint === 'review_confirm') {
event.reviewResult = normalizeReviewResult(intr.review_result ?? values.review_result) ?? undefined;
} else if (pausePoint === 'section_help' && Array.isArray(intr.blocked_sections)) {
event.blockedSections = (intr.blocked_sections as Record<string, unknown>[]).map(normalizeBlockedSection);
}
return event;
}
function buildRehydrateTimeline(values: AIWritingValues, sessionId: string): {
progressEvents: ProgressEvent[];
completedInterventions: CompletedIntervention[];
} {
const rawEvents = Array.isArray(values.progress_events)
? (values.progress_events as Record<string, unknown>[]).map((e) => normalizeProgressDict(e, sessionId))
: [];
const progressEvents: ProgressEvent[] = [];
const completedInterventions: CompletedIntervention[] = [];
for (const event of rawEvents) {
if (event.type === 'resumed') {
const classified = classifyResumedMessage(event.message);
if (classified) {
const pauseEvent = buildAwaitUserEventFromCheckpointPause(values, sessionId, classified.pausePoint);
const record = buildCompletedInterventionFromCheckpointPause(
values,
classified.pausePoint,
classified.action,
);
if (pauseEvent) progressEvents.push(pauseEvent);
if (record) completedInterventions.push(record);
}
}
progressEvents.push(event);
}
return { progressEvents, completedInterventions };
}
function normalizeBlockedSection(b: Record<string, unknown>): BlockedSection {
return {
sectionTitle: String(b.section_title ?? b.sectionTitle ?? ''),
@@ -561,6 +738,37 @@ export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId
return out as unknown as ProgressEvent;
}
/** Restore history: rebuild a frontend state snapshot from graph checkpoint values.
*
* ``status`` 后端与前端共用同一套字面量(state.py WritingStatus ↔ types/ai-writing.ts),可直接
* 透传;timeline 由累积的 ``progress_events``(operator.add 全量)重放,素材/大纲/草稿/审核读各自
* 快照字段。pause 点(currentPause)由 ``stream.interrupt`` 的既有 effect / host 另行设置。 */
export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: string): Partial<AIWritingState> {
const pkg = (values.material_package && typeof values.material_package === 'object')
? (values.material_package as Record<string, unknown>) : null;
const materials = pkg && Array.isArray(pkg.materials)
? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial) : [];
const skillSteps = pkg && Array.isArray(pkg.skill_search_steps ?? pkg.skillSearchSteps)
? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record<string, unknown>[]).map(normalizeSkillStep) : [];
const draft = (values.current_draft && typeof values.current_draft === 'object')
? (values.current_draft as Record<string, unknown>) : null;
const { progressEvents, completedInterventions } = buildRehydrateTimeline(values, sessionId);
return {
sessionId,
status: (values.status as AIWritingState['status']) || 'researching',
materials,
skillSearchSteps: skillSteps,
outline: normalizeOutline(values.current_outline),
currentDraftMarkdown: String(draft?.full_markdown ?? draft?.fullMarkdown ?? ''),
currentDraftTitle: String(draft?.title ?? ''),
revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0),
reviewResult: normalizeReviewResult(values.review_result),
progressEvents,
completedInterventions,
};
}
/** custom 通道事件 → ProgressEvent。事件 ``type`` 字段决定是哪类。 */
export function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null {
if (!raw || typeof raw !== 'object') return null;
@@ -732,7 +940,11 @@ export type AIWritingValues = {
add_references?: boolean;
/** 不收集素材,直接用通用知识写作(researcher 节点据此短路跳过检索)。 */
skip_research?: boolean;
model_name?: string;
status?: string;
revision_count?: number;
max_revisions?: number;
blocked_sections?: unknown[];
material_package?: unknown;
current_outline?: unknown;
current_draft?: unknown;
@@ -121,7 +121,7 @@ export function useWritingRewrite(editor: Editor | null) {
);
const rewrite = useCallback(
async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string, referenceText?: string) => {
if (!writingSelection || !editor) return;
abortRef.current?.abort();
@@ -143,6 +143,7 @@ export function useWritingRewrite(editor: Editor | null) {
after: docText.substring(to, Math.min(docSize, to + 400)),
},
customInstruction: customInstruction || undefined,
referenceText: referenceText || undefined,
};
setStatus("loading");
@@ -156,7 +157,7 @@ export function useWritingRewrite(editor: Editor | null) {
);
const rewriteFollowUp = useCallback(
async (instruction: string, action: RewriteAction, tone?: RewriteTone) => {
async (instruction: string, action: RewriteAction, tone?: RewriteTone, referenceText?: string) => {
if (!writingSelection || !editor) return;
const prevResult = result;
if (!prevResult) return;
@@ -179,6 +180,8 @@ export function useWritingRewrite(editor: Editor | null) {
before: docText.substring(Math.max(0, from - 400), from),
after: docText.substring(to, Math.min(docSize, to + 400)),
},
customInstruction: undefined,
referenceText: referenceText || undefined,
followUpInstruction: instruction,
previousResult: prevResult,
};
@@ -81,11 +81,26 @@
* -d '{"retention_days": 0}'
* ──────────────────────────────────────────────────────────────────────────
*/
import { useRef, useState } from 'react';
import { ArrowLeft, PanelRightOpen } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowLeft,
History,
Loader2,
PanelLeftClose,
PanelLeftOpen,
PanelRightOpen,
} from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { GroupImperativeHandle } from 'react-resizable-panels';
import {
Navigate,
Route,
Routes,
useLocation,
useNavigate,
useParams,
} from 'react-router-dom';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
@@ -96,7 +111,15 @@ import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { ResearchSidebar } from '../components/ai-writing/ResearchSidebar';
import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage';
import StudioNotebooksPage from '@/pages/StudioNotebooksPage';
import AgentChatPage from '@/pages/AgentChatPage';
import { ChatRuntime } from '@/pages/WorkspaceRoutes';
import {
AgentModuleSidebar,
useAgentModuleSidebarData,
} from '@/components/workspace/agents/agent-module-sidebar';
import { AgentModuleSettingsDialog } from '@/components/workspace/agents/agent-module-settings-dialog';
import { getStoredAuth } from '@/core/auth';
import { RouteBaseProvider } from '@/core/route-base';
/** 右侧内嵌视图:笔记本详情 / 所有空间列表 / null(写作) */
type RightView = { kind: 'notebook'; id: number } | { kind: 'allSpaces' } | null;
@@ -111,11 +134,83 @@ import {
// Panel IDs used as keys in the v4 Layout map
const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft';
const DRAFT_ID = 'aw-draft';
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
function useAIWritingRouteBase() {
const { pathname } = useLocation();
return useMemo(() => {
const marker = '/ai-writing';
const index = pathname.indexOf(marker);
return index >= 0
? pathname.slice(0, index + marker.length)
: '/page/workspace/ai-writing';
}, [pathname]);
}
function useAIWritingAgentRouteState(base: string) {
const { pathname } = useLocation();
return useMemo(() => {
const prefix = `${base}/agents/`;
if (!pathname.startsWith(prefix)) {
return { active: false, agentId: undefined, threadId: undefined };
}
const parts = pathname.slice(prefix.length).split('/');
return {
active: true,
agentId: parts[0] ? decodeURIComponent(parts[0]) : undefined,
threadId: parts[2] ? decodeURIComponent(parts[2]) : undefined,
};
}, [base, pathname]);
}
function AIWritingAgentDefaultRedirect() {
const { groups, isLoading } = useAgentModuleSidebarData(AI_WRITING_AGENT_MODULE_KEY);
const firstAgent = groups.flatMap((group) => group.agents)[0];
if (isLoading) {
return (
<div className="flex size-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
if (!firstAgent) {
return (
<div className="flex size-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
暂无可用智能体,请先在模块设置中配置或创建智能体。
</div>
);
}
return <Navigate to={`agents/${firstAgent.id}/chats/new`} replace />;
}
function AIWritingAgentChatRoute() {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage key={agent_id} />
</ChatRuntime>
);
}
function AIWritingAgentRoutes({ base }: { base: string }) {
return (
<RouteBaseProvider value={base}>
<Routes>
<Route index element={<AIWritingAgentDefaultRedirect />} />
<Route path="agents/:agent_id/chats/:thread_id" element={<AIWritingAgentChatRoute />} />
</Routes>
</RouteBaseProvider>
);
}
/**
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
*/
@@ -230,6 +325,11 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
const location = useLocation();
const navigate = useNavigate();
const routeBase = useAIWritingRouteBase();
const agentRoute = useAIWritingAgentRouteState(routeBase);
const isAdmin = getStoredAuth()?.system_role === 'admin';
// 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容
// (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。
@@ -237,26 +337,131 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
const overlayActive = rightView != null;
const viewingNotebookId = rightView?.kind === 'notebook' ? rightView.id : null;
// 进入写作(开始生成 / 查看历史)后收起研究模板侧栏;嵌入态(studioToken)始终不展示。
const showSidebar = !studioToken && state.status === 'idle' && !isHistoryMode;
// 侧栏「收起」态:由页面承载,ResearchSidebar 顶部的收起按钮触发,收起后显示一条细窄展开条。
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarTab, setSidebarTab] = useState<'research' | 'agents'>('agents');
const [settingsOpen, setSettingsOpen] = useState(false);
useEffect(() => {
if (agentRoute.active) {
setSidebarTab('agents');
}
}, [agentRoute.active]);
const agentsActive = sidebarTab === 'agents';
// 研究模板侧栏在「写作配置(idle)」与「历史回看」两态都展示(点历史记录后保留侧栏、
// 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。
const showSidebar =
!studioToken && (state.status === 'idle' || isHistoryMode || agentsActive);
return (
<div className="flex-1 min-h-0 overflow-hidden flex">
{/* 需求 0616 #5:课题研究左栏(研究模板 + 笔记本)。进入写作后收起。 */}
{/* 左栏:课题研究 / 智能体。课题研究支持收起为细窄展开条。 */}
{showSidebar && (
<ResearchSidebar
viewingNotebookId={viewingNotebookId}
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
onOpenAllSpaces={() => setRightView({ kind: 'allSpaces' })}
/>
<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',
sidebarCollapsed ? 'w-11' : 'w-[320px]',
)}
>
<div
className={cn(
'absolute inset-0 flex flex-col items-center gap-2 py-3 transition-opacity duration-200 ease-out',
sidebarCollapsed ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<button
type="button"
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"
>
<PanelLeftOpen className="size-4" />
</button>
<History className="size-4 text-slate-300" />
</div>
<aside
className={cn(
'absolute inset-y-0 left-0 flex h-full w-[320px] flex-col overflow-hidden transition-all duration-300 ease-in-out',
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">
<button
type="button"
onClick={() => {
setSidebarTab('agents');
setRightView(null);
if (!agentRoute.active) navigate(routeBase);
}}
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',
)}
>
课题研究
</button>
<button
type="button"
onClick={() => {
setSidebarTab('research');
setRightView(null);
if (location.pathname !== routeBase) navigate(routeBase);
}}
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',
)}
>
ai写作
</button>
</div>
<button
type="button"
onClick={() => setSidebarCollapsed(true)}
title="收起侧栏"
aria-label="收起侧栏"
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700"
>
<PanelLeftClose className="size-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{sidebarTab === 'research' ? (
<ResearchSidebar
viewingNotebookId={viewingNotebookId}
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
onOpenAllSpaces={() => setRightView({ kind: 'allSpaces' })}
/>
) : (
<AgentModuleSidebar
moduleKey={AI_WRITING_AGENT_MODULE_KEY}
activeAgentId={agentRoute.agentId}
activeThreadId={agentRoute.threadId}
routeBase={routeBase}
headerTitle={AI_WRITING_AGENT_MODULE_TITLE}
className="w-full border-r-0"
onOpenSettings={isAdmin ? () => setSettingsOpen(true) : undefined}
/>
)}
</div>
</aside>
</div>
)}
{/* 右侧:写作布局常驻(仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程),
查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
<div className="relative flex-1 min-h-0 overflow-hidden">
<div className={cn('absolute inset-0', overlayActive && 'hidden')}>
<div className={cn('absolute inset-0', (overlayActive || agentsActive) && 'hidden')}>
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div>
{overlayActive && (
{overlayActive && !agentsActive && (
<div className="absolute inset-0 flex flex-col bg-background">
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-2">
<button
@@ -293,7 +498,20 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
</div>
</div>
)}
{agentsActive && (
<div className="absolute inset-0 bg-background">
<AIWritingAgentRoutes base={routeBase} />
</div>
)}
</div>
{isAdmin && (
<AgentModuleSettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
moduleKey={AI_WRITING_AGENT_MODULE_KEY}
moduleTitle={AI_WRITING_AGENT_MODULE_TITLE}
/>
)}
</div>
);
}
@@ -311,30 +529,30 @@ export default function AIWritingPage({
} = {}) {
return (
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<AIWritingBody
hideAdvancedFields={hideAdvancedFields}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
/>
</div>
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</AIWritingHistoryProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<AIWritingBody
hideAdvancedFields={hideAdvancedFields}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
/>
</div>
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</AIWritingHistoryProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
);
}
@@ -11,7 +11,7 @@ export default function OpenCanvasRoutes() {
return (
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
<Route path="ai-writing/*" element={<AIWritingPage hideAdvancedFields />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
<Route path="research-templates/new" element={<ResearchTemplateEditorPage />} />
@@ -265,8 +265,9 @@ export interface AIWritingRequest {
writingMode: "strict" | "loose";
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
addReferences?: boolean;
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
materialSource: "general" | "notebook";
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/
* knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */
materialSource: "general" | "knowledge_base" | "notebook";
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
* 直接进入大纲规划;后端强制 loose 写作模式。默认 false。 */
skipResearch?: boolean;
+137 -27
View File
@@ -26,18 +26,19 @@ import { Tooltip } from "@/components/workspace/tooltip";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAgentSelector } from "@/core/agents";
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useRouteBase } from "@/core/route-base";
import {
getLastAgentId,
isThreadRagModeLocked,
saveLastAgentId,
useLocalSettings,
useThreadSettings,
} from "@/core/settings";
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
@@ -50,6 +51,22 @@ import { resolveIframeAssistantId } from "./iframe-chat-agent";
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons";
type BrainyChatStatus = "started" | "finished";
type ResolvedIframeModeButton =
| {
key: "agent";
label: string;
active: boolean;
disabled?: boolean;
}
| {
key: "writing" | "memory" | "references" | "contextCompression" | "pro";
label: string;
active: boolean;
disabled?: boolean;
icon: typeof FileTextIcon;
onClick: () => void;
activeClassName: string;
};
type BrainyAuthTokenMessage = {
source: "brainy-iframe";
type: "brainy:auth-token";
@@ -66,6 +83,13 @@ type BrainyChatInterruptMessage = {
};
requestId?: 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 +128,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 +176,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 +260,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 +332,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;
@@ -353,10 +441,22 @@ export default function IframeChatPage() {
}
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;
const nextSelection = resolveIncomingIframeSelection({
pageUrl: submitMessage.payload?.pageContext?.url,
mappings: pluginManagementSettings?.domainAgentMappings ?? [],
availableAgentIds: selectorLoading
? undefined
: selectorAgents.map((agent) => agent.id),
manualSelectedAgentId,
});
setSelectedAgentId(nextSelection.selectedAgentId);
setForceDefaultAssistant(nextSelection.forceDefaultAssistant);
console.log("收到插件消息", {
text: submitMessage.payload?.text,
prompt: submitMessage.payload?.prompt,
@@ -368,26 +468,31 @@ 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: nextSelection.selectedAgentId,
targetForceDefaultAssistant: nextSelection.forceDefaultAssistant,
});
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [handleInterrupt, handleSubmit]);
}, [
handleInterrupt,
manualSelectedAgentId,
pluginManagementSettings?.domainAgentMappings,
selectorAgents,
selectorLoading,
]);
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 +502,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 +547,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)}`);
}
});
@@ -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>
);
}
+11 -83
View File
@@ -3,9 +3,7 @@
import {
ArrowLeftIcon,
CheckCircleIcon,
InfoIcon,
SaveIcon,
SearchIcon,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -38,6 +36,7 @@ import {
type SetupAgentApprovalPayload,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents";
@@ -51,7 +50,7 @@ import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { isAgentSkillCandidate, type Skill } from "@/core/skills";
import { isAgentSkillCandidate } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
@@ -98,7 +97,6 @@ export default function NewAgentPage() {
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [skillSearch, setSkillSearch] = useState("");
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
const [threadPrepared, setThreadPrepared] = useState(!!resumeThreadId);
const { skills, isLoading: isSkillsLoading } = useSkills();
@@ -123,17 +121,6 @@ export default function NewAgentPage() {
[skills, storedAuth],
);
const filteredSkills = useMemo(() => {
const q = skillSearch.trim().toLowerCase();
if (!q) return enabledSkills;
return enabledSkills.filter(
(skill) =>
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q),
);
}, [enabledSkills, skillSearch]);
useEffect(() => {
if (isSkillSelectionTouched) {
return;
@@ -380,14 +367,10 @@ export default function NewAgentPage() {
[handleChatSubmit],
);
const toggleSkill = useCallback((skill: Skill) => {
const handleSkillSelectionChange = useCallback((next: string[]) => {
setIsSkillSelectionTouched(true);
setFormError("");
setSelectedSkills((prev) =>
prev.includes(skill.name)
? prev.filter((name) => name !== skill.name)
: [...prev, skill.name],
);
setSelectedSkills(next);
}, []);
const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
@@ -624,68 +607,13 @@ export default function NewAgentPage() {
已选 {selectedSkills.length} 个
</span>
</div>
{enabledSkills.length > 0 && (
<div className="relative">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能名称或描述…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.currentTarget.value)}
className="h-8 pl-7 text-xs"
/>
</div>
)}
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2 dark:border-[#1B477E] dark:bg-[#022248]">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能。
</p>
) : filteredSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
没有匹配的技能。
</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
key={skill.name}
className="flex w-full min-w-0 items-center"
>
<Button
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
title={skill.description}
className={cn(
"h-8 min-w-0 flex-1 rounded-r-none",
!checked && "text-muted-foreground",
)}
>
<span className="truncate">
{skill.name_zh?.trim() ? skill.name_zh : skill.name}
</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
<SkillTransferSelector
skills={enabledSkills}
selected={selectedSkills}
onChange={handleSkillSelectionChange}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
/>
</div>
<SkillDetailDialog
@@ -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>
);
}
+11
View File
@@ -51,6 +51,7 @@ import MemoryPage from "./MemoryPage";
import NewAgentPage from "./NewAgentPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
@@ -346,6 +347,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="settings/model-concurrency"
element={
getStoredAuth()?.system_role === "admin" ? (
<ModelConcurrencySettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
+27 -1
View File
@@ -31,6 +31,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 +42,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';
@@ -334,7 +336,7 @@ export default function WorkspaceRoutes({
}
/>
<Route
path="ai-writing"
path="ai-writing/*"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage hideAdvancedFields />
@@ -514,6 +516,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 +576,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)
@@ -86,31 +86,31 @@ export function DashboardApp({
<div className="h-1 bg-gradient-to-r from-rose-600 via-amber-500 to-sky-600 dark:from-[var(--dash-primary)] dark:via-[var(--dash-accent)] dark:to-[#4096ff] w-full" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-5 space-y-6">
{/* Header */}
<header className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 pb-4 border-b border-rose-100 dark:border-[var(--dash-border)]">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl sm:text-2xl font-black tracking-tight font-display flex items-center gap-1.5 text-rose-900 dark:text-[var(--dash-text-strong)]">
{data.meta.title}
</h1>
{/* Header(标题 + 副标题 + 生成时间)。战情多页(dashboard-agent 战情大屏)隐藏整块标题区,
只保留下方导航 Tab;会商 Step3 大屏照常展示。 */}
{!usingPages && (
<header className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 pb-4 border-b border-rose-100 dark:border-[var(--dash-border)]">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl sm:text-2xl font-black tracking-tight font-display flex items-center gap-1.5 text-rose-900 dark:text-[var(--dash-text-strong)]">
{data.meta.title}
</h1>
</div>
{data.meta.subtitle && (
<p className="text-xs sm:text-sm text-slate-500 dark:text-[var(--dash-text-sec)] font-sans tracking-wide">
{data.meta.subtitle}
</p>
)}
</div>
{data.meta.subtitle && (
<p className="text-xs sm:text-sm text-slate-500 dark:text-[var(--dash-text-sec)] font-sans tracking-wide">
{data.meta.subtitle}
</p>
)}
</div>
{/* 战情多页(dashboard-agent)不展示「生成于」时间标签;会商 Step3 大屏保留。 */}
{!usingPages && (
<div className="flex flex-wrap items-center gap-2.5 self-start lg:self-center text-xs text-slate-500 dark:text-[var(--dash-text-sec)]">
<div className="flex items-center gap-1.5 font-mono px-3 py-1.5 bg-white dark:bg-[var(--dash-card)] border border-slate-200 dark:border-[var(--dash-border)] shadow-sm rounded-sm">
<Clock className="w-3.5 h-3.5 text-rose-600 dark:text-[var(--dash-primary)] animate-pulse" />
<span>生成于 {data.meta.generatedAt}</span>
</div>
</div>
)}
</header>
</header>
)}
{/* 导航 Tab */}
<nav className="flex items-center justify-start overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
@@ -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, {
@@ -7,6 +7,7 @@
import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/light-apps";
import { authStorageGet } from "@/core/auth/scoped-storage";
import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
/** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */
export const MOUNT_OPTIONS: { label: string; value: string }[] = [
@@ -142,6 +143,9 @@ export function resolveRouteParamValue(
}
if (param.source) {
const value = userInfo[param.source];
if (param.source === "accessToken") {
return normalizeExternalAccessToken(value);
}
return value == null ? "" : String(value);
}
return "";
+1 -1
View File
@@ -273,7 +273,7 @@ The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four s
Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets/<id>/`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`.
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. 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`.
**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`.
**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 "检不出".
@@ -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,7 @@ from __future__ import annotations
import logging
from typing import Literal
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
@@ -13,6 +14,9 @@ from app.gateway.deps import get_optional_user_from_request
from deerflow.config import get_app_config
from deerflow.config.system_settings import (
LeaderboardSettings,
ModelConcurrencySettings,
PluginDomainAgentMapping,
PluginManagementSettings,
PromptPrefixSettings,
PublishSquare,
PublishSquaresSettings,
@@ -42,6 +46,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 +109,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 +163,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 +219,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 +239,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 +297,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])),
@@ -42,8 +42,12 @@ _ACTION_INSTRUCTIONS: dict[str, str] = {
"expand": "请对以下文字进行扩写,补充细节和说明,使内容更丰富饱满。",
"shorten": "请对以下文字进行缩写,保留核心意思,去除冗余表达,使其更简洁。",
"tone": "请调整以下文字的语气风格。",
"imitate": "请模仿给定范文的写作风格、句式与语气,对以下文字进行仿写改写,在保留原文核心信息的前提下尽量贴近范文的表达方式。",
}
# 仿写参考范文的最大长度(防止超长 prompt)
_REFERENCE_MAX_CHARS = 2000
_TONE_SUFFIX: dict[str, str] = {
"professional": "目标风格:专业、正式。",
"casual": "目标风格:轻松、口语化。",
@@ -72,11 +76,12 @@ class SurroundingContext(BaseModel):
class RewriteRequest(BaseModel):
thread_id: str | None = Field(default=None, alias="threadId")
selection: SelectionInfo
action: str = Field(..., description="polish | expand | shorten | tone")
action: str = Field(..., description="polish | expand | shorten | tone | imitate")
tone: str | None = Field(default=None)
model_name: str | None = Field(default=None, alias="modelName")
surrounding_context: SurroundingContext = Field(default_factory=SurroundingContext, alias="surroundingContext")
custom_instruction: str | None = Field(default=None, alias="customInstruction")
reference_text: str | None = Field(default=None, alias="referenceText", description="仿写参考范文(action=imitate)")
follow_up_instruction: str | None = Field(default=None, alias="followUpInstruction")
previous_result: str | None = Field(default=None, alias="previousResult")
@@ -121,18 +126,15 @@ def _extract_chunk_parts(chunk: object) -> tuple[str, str]:
return thinking, text
# ── Endpoint ──────────────────────────────────────────────────────────────────
# ── Prompt building (pure, testable) ──────────────────────────────────────────
@router.post(
"/rewrite",
summary="AI Text Rewrite (SSE stream)",
response_class=StreamingResponse,
)
async def rewrite_selection(
body: RewriteRequest,
config: AppConfig = Depends(get_config),
) -> StreamingResponse:
def build_rewrite_messages(body: RewriteRequest) -> tuple[str, list]:
"""Build (resolved_action, messages) for the rewrite request.
Pure function (no model / IO) so the prompt assembly — including 仿写
(imitate) reference-sample injection — can be unit-tested directly.
"""
action = body.action if body.action in _ACTION_INSTRUCTIONS else "polish"
instruction = _ACTION_INSTRUCTIONS[action]
if action == "tone" and body.tone and body.tone in _TONE_SUFFIX:
@@ -153,6 +155,9 @@ async def rewrite_selection(
base_instruction = instruction
if body.custom_instruction:
base_instruction = f"{instruction}\n用户额外要求:{body.custom_instruction}"
if action == "imitate" and body.reference_text and body.reference_text.strip():
reference = body.reference_text.strip()[:_REFERENCE_MAX_CHARS]
base_instruction = f"{base_instruction}\n\n【范文】(请模仿其文风、句式、语气与结构,但不要照抄其具体内容):\n{reference}"
user_message = f"{base_instruction}\n\n{context_block}需要改写的文字:\n{body.selection.text}"
if body.follow_up_instruction and body.previous_result:
@@ -165,6 +170,23 @@ async def rewrite_selection(
else:
messages = [SystemMessage(content=_SYSTEM_PROMPT), HumanMessage(content=user_message)]
return action, messages
# ── Endpoint ──────────────────────────────────────────────────────────────────
@router.post(
"/rewrite",
summary="AI Text Rewrite (SSE stream)",
response_class=StreamingResponse,
)
async def rewrite_selection(
body: RewriteRequest,
config: AppConfig = Depends(get_config),
) -> StreamingResponse:
action, messages = build_rewrite_messages(body)
# Auto-enable thinking when the selected model supports it
resolved_name = body.model_name or (config.models[0].name if config.models else None)
model_cfg = config.get_model_config(resolved_name) if resolved_name else None
File diff suppressed because it is too large. Load diff
@@ -440,6 +440,7 @@ ai_writing:
# 接口契约:POST {url} 体 {"query","knowledge_base_name","ts":"yes"} → [{"page_content","metadata":{m_title,content,recUuid,m_publish,source1}}]
intranet_search_url: "" # 例如 http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: "samples" # 兜底检索用的知识库名
intranet_search_timeout: 300 # 内网检索超时时间(秒),代码层强制最低 300;需要更长可调大
# 内网接口诊断开关:开启后即使技能已检索成功,也照样把内网接口跑完并把执行结果
# (端点/每个检索词命中数/报错/原始响应片段)记到 material_package.intranet_debug 里,
# 便于在接口返回(devtools)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
@@ -594,6 +594,7 @@ async def run_skill_search_agent(
results: list = []
seen_urls: set[str] = set()
last_error = ""
search_timeout = max(timeout_s, _REAL_AGENT_TIMEOUT_SECONDS)
for _turn in range(_MAX_TURNS):
try:
@@ -616,10 +617,10 @@ async def run_skill_search_agent(
emit_query(query)
try:
found = await asyncio.wait_for(
provider.search(query, max_results=max_results), timeout=timeout_s
provider.search(query, max_results=max_results), timeout=search_timeout
)
except asyncio.TimeoutError:
last_error = f"检索超时(>{timeout_s}s)"
last_error = f"检索超时(>{search_timeout}s)"
messages.append(ToolMessage(content=last_error, tool_call_id=tc_id))
continue
except Exception as e: # noqa: BLE001
@@ -1,12 +1,16 @@
"""素材收集专家节点:关键词提取 → 配置技能编排检索(含笔记) → 相关度排序。
检索类型(material_source)只有两种:
检索类型(material_source)有三种:
- ``general``(通用检索,默认):按素材收集专家 agent(ai-writing-researcher)
config.yaml 配置的技能顺序编排检索。内置「知识库检索」技能
(knowledge-base-search)走原生 web/intranet 关键词并发检索;其它技能用
其 description 生成定向 query 后调真实 provider 检索。
- ``knowledge_base``(知识库检索 / 内网直连检索):**直连内网知识库 ES 接口**
(``IntranetSearchProvider``),不编排任何技能、也不跑通用 provider。接口地址取
``ai_writing.intranet_search_url``,**留空时回退 provider 内置默认内网端点**——与旧版
``get_search_provider(search_provider=intranet)`` 一致,即便没配地址内网直连仍可用。
- ``notebook``(我的空间):仅用笔记 API 素材。
旧值 ``knowledge_base`` / ``skill`` 一律按 ``general`` 兼容处理。
旧值 ``skill`` 仍按 ``general`` 兼容处理。
PAUSE-1 素材确认卡的单输入框:用户用自然语言描述想补充检索的内容
(如「检索 A 的数据」)时,让模型从已配置技能中按相关度匹配,逐个检索,
@@ -98,11 +102,44 @@ _KNOWLEDGE_SKILL_DISPLAY = "知识库检索"
_KNOWLEDGE_SKILL_FALLBACK_DESC = "对写作意图提炼的检索词执行 web/intranet 知识库检索,获取相关素材"
def _intranet_timeout_seconds(ai_writing_config: dict) -> int:
"""内网检索超时统一入口:默认 10 分钟,任何配置都至少 5 分钟。"""
from ..search.intranet_stub import normalize_intranet_timeout
raw = (ai_writing_config or {}).get("intranet_search_timeout")
return normalize_intranet_timeout(raw)
def _fallback_knowledge_skill() -> SimpleNamespace:
"""技能目录里找不到 knowledge-base-search 时的内存兜底(原生路径不依赖 SKILL.md)。"""
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)配置的技能列表。
@@ -414,9 +451,10 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if requested_append_count is not None:
effective_user_query = residual_topic or intent
# 用户在写作表单选择的检索类型:general(通用检索,按 agent 配置技能编排)/
# notebook(我的空间)。旧值 knowledge_base / skill 一律按 general 兼容。
# knowledge_base(知识库检索,直连内网 ES 接口)/ notebook(我的空间)。
# 旧值 skill 一律按 general 兼容。
raw_source = (state.get("material_source") or "general").strip()
material_source = "notebook" if raw_source == "notebook" else "general"
material_source = raw_source if raw_source in ("notebook", "knowledge_base") else "general"
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
@@ -563,6 +601,111 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 用户在表单选了「我的空间」作为检索类型 → 跳过技能编排,
# 仅使用 Step 1.5 已拉取的 notebook_materials。
_emit(f"仅使用笔记素材,跳过技能检索({len(notebook_materials)} 条)")
elif material_source == "knowledge_base":
# ── 知识库检索:直连内网 ES 接口(恢复旧版「内网直连检索」可用逻辑)──
# 用户在表单选了「知识库检索」→ 跳过技能编排、跳过通用 provider,直接调内网知识库
# ES 接口(IntranetSearchProvider)对检索词并发检索。接口地址优先取 config.yaml 的
# ai_writing.intranet_search_url;**留空时回退到 IntranetSearchProvider 内置默认端点**
# (历史上一直可用的内网 ES 地址),与旧版 get_search_provider(search_provider=intranet)
# 完全一致——所以即便没配地址,内网直连依然可用,不再短路成「未配置 → 无素材」。
from ..search.intranet_stub import IntranetSearchProvider
intranet_url = (ai_writing_config.get("intranet_search_url") or "").strip()
provider = IntranetSearchProvider(
base_url=intranet_url, # 空串 → provider 内部回退默认内网端点(旧逻辑)
knowledge_base_name=ai_writing_config.get("intranet_knowledge_base", "samples"),
# 内网慢检索超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,
# 默认 600s,且最低 300s。
timeout=_intranet_timeout_seconds(ai_writing_config),
)
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),
"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]
total = len(search_keywords)
per_keyword = _per_keyword_limit(max_materials, total)
_emit(f"知识库检索:直连内网接口检索 {total} 个方向…")
_emit_skill_step({
"type": "invoke_skill",
"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(
*[_search_one_keyword(kw) for kw in search_keywords],
return_exceptions=True,
)
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 [])
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": result_message,
"skill_name": KNOWLEDGE_SEARCH_SKILL_NAME,
"matched_count": len(new_results),
})
elif is_append_only:
# ── 兼容旧版追加关键词模式 ──
# 直接对用户给的关键词做原生并发检索(不走技能编排),结果追加到旧素材。
@@ -783,10 +926,11 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"configured": bool(intranet_url),
"endpoint": intranet_url or "(未配置 ai_writing.intranet_search_url)",
"knowledge_base": ai_writing_config.get("intranet_knowledge_base", "samples"),
"timeout": ai_writing_config.get("intranet_search_timeout", 600),
"timeout": _intranet_timeout_seconds(ai_writing_config),
"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, # 内网总命中条数
@@ -803,8 +947,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
base_url=intranet_url,
knowledge_base_name=ai_writing_config.get("intranet_knowledge_base", "samples"),
# 兜底接口超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,
# 默认 600s(10 分钟),给内网慢检索留足时间。
timeout=ai_writing_config.get("intranet_search_timeout", 600),
# 默认 600s(10 分钟),且最低 300s。
timeout=_intranet_timeout_seconds(ai_writing_config),
)
intranet_holder["provider"] = intranet
fb_keywords = keywords[:keyword_count] or [intent]
@@ -821,8 +965,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
continue
intranet_debug["queries"].append({"query": kw, "count": len(res or [])})
collected.extend(res or [])
intranet_debug["errors"] = list(dict.fromkeys(getattr(intranet, "errors", [])))[:5]
intranet_debug["raw_responses"] = list(dict.fromkeys(getattr(intranet, "raw_responses", [])))[:3]
intranet_debug["errors"] = _unique_limited(getattr(intranet, "errors", []) or [], 5)
intranet_debug["raw_responses"] = _unique_limited(getattr(intranet, "raw_responses", []) or [], 3)
intranet_debug["calls"] = list(getattr(intranet, "calls", []) or [])[:20]
intranet_debug["result_count"] = len(collected)
intranet_debug["status"] = "ran"
return collected
@@ -1,6 +1,6 @@
from .base import BaseSearchProvider, SearchResult
from .internet_search import InternetSearchProvider
from .intranet_stub import IntranetSearchProvider
from .intranet_stub import IntranetSearchProvider, normalize_intranet_timeout
from .demo_search import DemoSearchProvider
from .duckduckgo_search import DuckDuckGoSearchProvider
from .skill_search import SkillSearchProvider
@@ -13,6 +13,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
search_provider: internet # internet(默认,DDG) / intranet / tavily / demo
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: samples
intranet_search_timeout: 300 # 内网检索超时秒数,最少 300 秒
ddg_region: "cn-zh" # DuckDuckGo 区域,默认 cn-zh
internet 模式:DuckDuckGo 为主搜索(免 API Key),失败时 fallback 到 demo 演示素材。
@@ -24,6 +25,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
return IntranetSearchProvider(
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")),
)
if provider == "tavily":
return _FallbackSearchProvider(InternetSearchProvider(), DemoSearchProvider())
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import time
from typing import List
from .base import BaseSearchProvider, SearchResult, simplify_query
@@ -11,10 +12,22 @@ logger = logging.getLogger(__name__)
# 内网知识库默认检索接口与知识库名(可经 config.yaml 的 ai_writing 段覆盖)
_DEFAULT_ENDPOINT = "http://10.106.57.180:7863/knowledge_base/search_docs_es"
_DEFAULT_KB = "samples"
# 内网检索最少等待 5 分钟;该接口经常慢返回,低于这个值会造成素材收集误判为空。
_MIN_TIMEOUT_SECONDS = 300
_DEFAULT_TIMEOUT_SECONDS = 600
# 单条原始响应体最大保留长度,避免把超大响应整段灌进前端时间线
_RAW_SNIPPET_LIMIT = 2000
def normalize_intranet_timeout(timeout: object, default: int = _DEFAULT_TIMEOUT_SECONDS) -> int:
"""返回内网检索超时秒数,并强制不低于 5 分钟。"""
try:
value = int(timeout)
except (TypeError, ValueError):
value = default
return max(value, _MIN_TIMEOUT_SECONDS)
def _pick(d: dict, keys: tuple) -> str:
"""从结果项里按候选字段名取第一个非空值(两套接口字段名不同,做并集兼容)。"""
for k in keys:
@@ -26,6 +39,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):
"""内网知识库搜索接口适配器。
@@ -38,30 +61,26 @@ class IntranetSearchProvider(BaseSearchProvider):
self,
base_url: str = "",
knowledge_base_name: str = _DEFAULT_KB,
timeout: int = 600,
timeout: int = _DEFAULT_TIMEOUT_SECONDS,
**_ignored,
):
self.endpoint = base_url or _DEFAULT_ENDPOINT
self.knowledge_base_name = knowledge_base_name or _DEFAULT_KB
# 内网 ES 接口很慢,兜底检索超时放宽到 10 分钟(不依赖 env 配置);构造方可传更大值覆盖。
# 「咱们先把自己的做好」:我方兜底超时给足,避免因我方过早超时被甩锅成「检不出」。
try:
self.timeout = max(int(timeout), 1)
except (TypeError, ValueError):
self.timeout = 600
# 内网 ES 接口很慢,兜底检索超时默认 10 分钟,且无论配置如何至少 5 分钟。
self.timeout = normalize_intranet_timeout(timeout)
# 收集本次运行的检索异常,供 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]:
results = await self._search_once(query, max_results)
@@ -84,40 +103,87 @@ 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,
}
try:
async with httpx.AsyncClient(timeout=self.timeout) 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 "")
message = f"{type(e).__name__}: {e}"
self.errors.append(message)
body = e.response.text if e.response is not None else ""
call.update({
"status": "http_error",
"error": message,
"http_status": getattr(e.response, "status_code", None),
})
snippet = _raw_snippet(body)
if snippet:
call["raw_response"] = snippet
self._record_raw(body)
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}")
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 []
@@ -150,6 +216,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 -)。
@@ -141,8 +144,9 @@ class AIWritingState(TypedDict):
# 是否在成稿里加参考文献:正文引用素材处加角标 [n],文末按出现顺序生成「参考文献」列表(像论文)。
add_references: bool
# 检索类型:general=通用检索(按素材收集专家 agent 配置的技能编排,默认)/
# notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher
# 节点按 general 兼容处理。
# knowledge_base=知识库检索(直连 ai_writing.intranet_search_url 内网 ES 接口,
# 不走技能编排——弱模型识别不了技能时用这条)/ notebook=我的空间(仅笔记检索)。
# 旧值 skill 由 researcher 节点按 general 兼容处理。
material_source: str
# 跳过素材收集:用户在初始表单勾选「不收集素材,直接用通用知识写作」时为 True。
# researcher 节点在**首次**进入(无任何 pause 干预)时短路,跳过整个检索流程直接
@@ -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
@@ -206,6 +206,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 +269,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 +300,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 +347,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()
@@ -1,46 +1,57 @@
"""LangGraph-compatible runtime — runs, streaming, and lifecycle management.
Re-exports the public API of :mod:`~deerflow.runtime.runs` and
:mod:`~deerflow.runtime.stream_bridge` so that consumers can import
directly from ``deerflow.runtime``.
"""
from .checkpointer import checkpointer_context, get_checkpointer, make_checkpointer, reset_checkpointer
from .runs import ConflictError, DisconnectMode, RunContext, RunManager, RunRecord, RunStatus, UnsupportedStrategyError, run_agent
from .serialization import serialize, serialize_channel_values, serialize_lc_object, serialize_messages_tuple
from .store import get_store, make_store, reset_store, store_context
from .stream_bridge import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, StreamBridge, StreamEvent, make_stream_bridge
__all__ = [
# checkpointer
"checkpointer_context",
"get_checkpointer",
"make_checkpointer",
"reset_checkpointer",
# runs
"ConflictError",
"DisconnectMode",
"RunContext",
"RunManager",
"RunRecord",
"RunStatus",
"UnsupportedStrategyError",
"run_agent",
# serialization
"serialize",
"serialize_channel_values",
"serialize_lc_object",
"serialize_messages_tuple",
# store
"get_store",
"make_store",
"reset_store",
"store_context",
# stream_bridge
"END_SENTINEL",
"HEARTBEAT_SENTINEL",
"MemoryStreamBridge",
"StreamBridge",
"StreamEvent",
"make_stream_bridge",
]
"""LangGraph-compatible runtime — runs, streaming, and lifecycle management.
Re-exports the public API of :mod:`~deerflow.runtime.runs` and
:mod:`~deerflow.runtime.stream_bridge` so that consumers can import
directly from ``deerflow.runtime``.
"""
from .checkpointer import checkpointer_context, get_checkpointer, make_checkpointer, reset_checkpointer
from .runs import (
ConflictError,
DisconnectMode,
ModelConcurrencyLimitError,
RunContext,
RunManager,
RunRecord,
RunStatus,
UnsupportedStrategyError,
run_agent,
)
from .serialization import serialize, serialize_channel_values, serialize_lc_object, serialize_messages_tuple
from .store import get_store, make_store, reset_store, store_context
from .stream_bridge import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, StreamBridge, StreamEvent, make_stream_bridge
__all__ = [
# checkpointer
"checkpointer_context",
"get_checkpointer",
"make_checkpointer",
"reset_checkpointer",
# runs
"ConflictError",
"DisconnectMode",
"ModelConcurrencyLimitError",
"RunContext",
"RunManager",
"RunRecord",
"RunStatus",
"UnsupportedStrategyError",
"run_agent",
# serialization
"serialize",
"serialize_channel_values",
"serialize_lc_object",
"serialize_messages_tuple",
# store
"get_store",
"make_store",
"reset_store",
"store_context",
# stream_bridge
"END_SENTINEL",
"HEARTBEAT_SENTINEL",
"MemoryStreamBridge",
"StreamBridge",
"StreamEvent",
"make_stream_bridge",
]
@@ -1,16 +1,17 @@
"""Run lifecycle management for LangGraph Platform API compatibility."""
from .manager import ConflictError, RunManager, RunRecord, UnsupportedStrategyError
from .schemas import DisconnectMode, RunStatus
from .worker import RunContext, run_agent
__all__ = [
"ConflictError",
"DisconnectMode",
"RunContext",
"RunManager",
"RunRecord",
"RunStatus",
"UnsupportedStrategyError",
"run_agent",
]
"""Run lifecycle management for LangGraph Platform API compatibility."""
from .manager import ConflictError, ModelConcurrencyLimitError, RunManager, RunRecord, UnsupportedStrategyError
from .schemas import DisconnectMode, RunStatus
from .worker import RunContext, run_agent
__all__ = [
"ConflictError",
"DisconnectMode",
"ModelConcurrencyLimitError",
"RunContext",
"RunManager",
"RunRecord",
"RunStatus",
"UnsupportedStrategyError",
"run_agent",
]
@@ -1,310 +1,326 @@
"""In-memory run registry with optional persistent RunStore backing."""
from __future__ import annotations
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from deerflow.utils.time import now_iso as _now_iso
from .schemas import DisconnectMode, RunStatus
if TYPE_CHECKING:
from deerflow.runtime.runs.store.base import RunStore
logger = logging.getLogger(__name__)
@dataclass
class RunRecord:
"""Mutable record for a single run."""
run_id: str
thread_id: str
assistant_id: str | None
status: RunStatus
on_disconnect: DisconnectMode
multitask_strategy: str = "reject"
metadata: dict = field(default_factory=dict)
kwargs: dict = field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
task: asyncio.Task | None = field(default=None, repr=False)
abort_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
abort_action: str = "interrupt"
error: str | None = None
class RunManager:
"""In-memory run registry with optional persistent RunStore backing.
All mutations are protected by an asyncio lock. When a ``store`` is
provided, serializable metadata is also persisted to the store so
that run history survives process restarts.
"""
def __init__(self, store: RunStore | None = None) -> None:
self._runs: dict[str, RunRecord] = {}
self._lock = asyncio.Lock()
self._store = store
async def _persist_to_store(self, record: RunRecord) -> None:
"""Best-effort persist run record to backing store."""
if self._store is None:
return
try:
await self._store.put(
record.run_id,
thread_id=record.thread_id,
assistant_id=record.assistant_id,
status=record.status.value,
multitask_strategy=record.multitask_strategy,
metadata=record.metadata or {},
kwargs=record.kwargs or {},
created_at=record.created_at,
)
except Exception:
logger.warning("Failed to persist run %s to store", record.run_id, exc_info=True)
async def update_run_completion(self, run_id: str, **kwargs) -> None:
"""Persist token usage and completion data to the backing store."""
if self._store is not None:
try:
await self._store.update_run_completion(run_id, **kwargs)
except Exception:
logger.warning("Failed to persist run completion for %s", run_id, exc_info=True)
async def create(
self,
thread_id: str,
assistant_id: str | None = None,
*,
on_disconnect: DisconnectMode = DisconnectMode.cancel,
metadata: dict | None = None,
kwargs: dict | None = None,
multitask_strategy: str = "reject",
) -> RunRecord:
"""Create a new pending run and register it."""
run_id = str(uuid.uuid4())
now = _now_iso()
record = RunRecord(
run_id=run_id,
thread_id=thread_id,
assistant_id=assistant_id,
status=RunStatus.pending,
on_disconnect=on_disconnect,
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
created_at=now,
updated_at=now,
)
async with self._lock:
self._runs[run_id] = record
await self._persist_to_store(record)
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
return record
def get(self, run_id: str) -> RunRecord | None:
"""Return a run record by ID, or ``None``."""
return self._runs.get(run_id)
def _record_from_store(self, data: dict) -> RunRecord:
"""Rebuild a :class:`RunRecord` from a persisted store row.
The reconstructed record has no live ``task``/``abort_event`` wired to a
running coroutine — it represents *history*, not an in-flight run.
"""
try:
status = RunStatus(data.get("status") or RunStatus.success.value)
except ValueError:
status = RunStatus.success
return RunRecord(
run_id=data["run_id"],
thread_id=data["thread_id"],
assistant_id=data.get("assistant_id"),
status=status,
# Disconnect mode is irrelevant for a store-only record (no task to
# cancel); ``continue_`` keeps sse_consumer's finally block a no-op.
on_disconnect=DisconnectMode.continue_,
multitask_strategy=data.get("multitask_strategy", "reject"),
metadata=data.get("metadata") or {},
kwargs=data.get("kwargs") or {},
created_at=data.get("created_at") or "",
updated_at=data.get("updated_at") or "",
error=data.get("error"),
)
async def get_persisted(self, run_id: str) -> RunRecord | None:
"""Load a run from the backing store (process-restart fallback).
The in-memory registry is wiped on every Gateway restart, but the
``RunStore`` row survives. When :meth:`get` misses, callers fall back
here so a stale ``joinStream``/``GET run`` can report the run's final
status instead of 404ing. Returns ``None`` when there is no store, no
matching row, or the lookup fails (e.g. no user context).
"""
if self._store is None:
return None
try:
data = await self._store.get(run_id)
except Exception:
logger.warning("Failed to load run %s from store", run_id, exc_info=True)
return None
if not data:
return None
return self._record_from_store(data)
async def list_by_thread(self, thread_id: str) -> list[RunRecord]:
"""Return all runs for a given thread, newest first."""
async with self._lock:
# Dict insertion order matches creation order, so reversing it gives
# us deterministic newest-first results even when timestamps tie.
return [r for r in self._runs.values() if r.thread_id == thread_id]
async def set_status(self, run_id: str, status: RunStatus, *, error: str | None = None) -> None:
"""Transition a run to a new status."""
async with self._lock:
record = self._runs.get(run_id)
if record is None:
logger.warning("set_status called for unknown run %s", run_id)
return
record.status = status
record.updated_at = _now_iso()
if error is not None:
record.error = error
if self._store is not None:
try:
await self._store.update_status(run_id, status.value, error=error)
except Exception:
logger.warning("Failed to persist status update for run %s", run_id, exc_info=True)
logger.info("Run %s -> %s", run_id, status.value)
async def cancel(self, run_id: str, *, action: str = "interrupt") -> bool:
"""Request cancellation of a run.
Args:
run_id: The run ID to cancel.
action: "interrupt" keeps checkpoint, "rollback" reverts to pre-run state.
Sets the abort event with the action reason and cancels the asyncio task.
Returns ``True`` if the run was in-flight and cancellation was initiated.
"""
async with self._lock:
record = self._runs.get(run_id)
if record is None:
return False
if record.status not in (RunStatus.pending, RunStatus.running):
return False
record.abort_action = action
record.abort_event.set()
if record.task is not None and not record.task.done():
record.task.cancel()
record.status = RunStatus.interrupted
record.updated_at = _now_iso()
logger.info("Run %s cancelled (action=%s)", run_id, action)
return True
async def create_or_reject(
self,
thread_id: str,
assistant_id: str | None = None,
*,
on_disconnect: DisconnectMode = DisconnectMode.cancel,
metadata: dict | None = None,
kwargs: dict | None = None,
multitask_strategy: str = "reject",
) -> RunRecord:
"""Atomically check for inflight runs and create a new one.
For ``reject`` strategy, raises ``ConflictError`` if thread
already has a pending/running run. For ``interrupt``/``rollback``,
cancels inflight runs before creating.
This method holds the lock across both the check and the insert,
eliminating the TOCTOU race in separate ``has_inflight`` + ``create``.
"""
run_id = str(uuid.uuid4())
now = _now_iso()
_supported_strategies = ("reject", "interrupt", "rollback")
async with self._lock:
if multitask_strategy not in _supported_strategies:
raise UnsupportedStrategyError(f"Multitask strategy '{multitask_strategy}' is not yet supported. Supported strategies: {', '.join(_supported_strategies)}")
inflight = [r for r in self._runs.values() if r.thread_id == thread_id and r.status in (RunStatus.pending, RunStatus.running)]
if multitask_strategy == "reject" and inflight:
raise ConflictError(f"Thread {thread_id} already has an active run")
if multitask_strategy in ("interrupt", "rollback") and inflight:
for r in inflight:
r.abort_action = multitask_strategy
r.abort_event.set()
if r.task is not None and not r.task.done():
r.task.cancel()
r.status = RunStatus.interrupted
r.updated_at = now
logger.info(
"Cancelled %d inflight run(s) on thread %s (strategy=%s)",
len(inflight),
thread_id,
multitask_strategy,
)
record = RunRecord(
run_id=run_id,
thread_id=thread_id,
assistant_id=assistant_id,
status=RunStatus.pending,
on_disconnect=on_disconnect,
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
created_at=now,
updated_at=now,
)
self._runs[run_id] = record
await self._persist_to_store(record)
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
return record
async def has_inflight(self, thread_id: str) -> bool:
"""Return ``True`` if *thread_id* has a pending or running run."""
async with self._lock:
return any(r.thread_id == thread_id and r.status in (RunStatus.pending, RunStatus.running) for r in self._runs.values())
async def list_active(self) -> list[RunRecord]:
"""Return every in-flight run (status pending/running), oldest first.
Powers the admin concurrency monitor: each record represents one live
call into the model runtime (interactive chat, agent chat, roundtable
seat, …), so ``len(list_active())`` is the current concurrent-call count
and each record can be cancelled to relieve model pressure. Completed
records linger in ``_runs`` until :meth:`cleanup`, so we filter by status
rather than returning the whole registry.
"""
async with self._lock:
# Dict insertion order == creation order, so this is oldest-first.
return [r for r in self._runs.values() if r.status in (RunStatus.pending, RunStatus.running)]
async def cleanup(self, run_id: str, *, delay: float = 300) -> None:
"""Remove a run record after an optional delay."""
if delay > 0:
await asyncio.sleep(delay)
async with self._lock:
self._runs.pop(run_id, None)
logger.debug("Run record %s cleaned up", run_id)
class ConflictError(Exception):
"""Raised when multitask_strategy=reject and thread has inflight runs."""
class UnsupportedStrategyError(Exception):
"""Raised when a multitask_strategy value is not yet implemented."""
"""In-memory run registry with optional persistent RunStore backing."""
from __future__ import annotations
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from deerflow.utils.time import now_iso as _now_iso
from .schemas import DisconnectMode, RunStatus
if TYPE_CHECKING:
from deerflow.runtime.runs.store.base import RunStore
logger = logging.getLogger(__name__)
@dataclass
class RunRecord:
"""Mutable record for a single run."""
run_id: str
thread_id: str
assistant_id: str | None
status: RunStatus
on_disconnect: DisconnectMode
multitask_strategy: str = "reject"
metadata: dict = field(default_factory=dict)
kwargs: dict = field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
task: asyncio.Task | None = field(default=None, repr=False)
abort_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
abort_action: str = "interrupt"
error: str | None = None
class RunManager:
"""In-memory run registry with optional persistent RunStore backing.
All mutations are protected by an asyncio lock. When a ``store`` is
provided, serializable metadata is also persisted to the store so
that run history survives process restarts.
"""
def __init__(self, store: RunStore | None = None) -> None:
self._runs: dict[str, RunRecord] = {}
self._lock = asyncio.Lock()
self._store = store
async def _persist_to_store(self, record: RunRecord) -> None:
"""Best-effort persist run record to backing store."""
if self._store is None:
return
try:
await self._store.put(
record.run_id,
thread_id=record.thread_id,
assistant_id=record.assistant_id,
status=record.status.value,
multitask_strategy=record.multitask_strategy,
metadata=record.metadata or {},
kwargs=record.kwargs or {},
created_at=record.created_at,
)
except Exception:
logger.warning("Failed to persist run %s to store", record.run_id, exc_info=True)
async def update_run_completion(self, run_id: str, **kwargs) -> None:
"""Persist token usage and completion data to the backing store."""
if self._store is not None:
try:
await self._store.update_run_completion(run_id, **kwargs)
except Exception:
logger.warning("Failed to persist run completion for %s", run_id, exc_info=True)
async def create(
self,
thread_id: str,
assistant_id: str | None = None,
*,
on_disconnect: DisconnectMode = DisconnectMode.cancel,
metadata: dict | None = None,
kwargs: dict | None = None,
multitask_strategy: str = "reject",
) -> RunRecord:
"""Create a new pending run and register it."""
run_id = str(uuid.uuid4())
now = _now_iso()
record = RunRecord(
run_id=run_id,
thread_id=thread_id,
assistant_id=assistant_id,
status=RunStatus.pending,
on_disconnect=on_disconnect,
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
created_at=now,
updated_at=now,
)
async with self._lock:
self._runs[run_id] = record
await self._persist_to_store(record)
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
return record
def get(self, run_id: str) -> RunRecord | None:
"""Return a run record by ID, or ``None``."""
return self._runs.get(run_id)
def _record_from_store(self, data: dict) -> RunRecord:
"""Rebuild a :class:`RunRecord` from a persisted store row.
The reconstructed record has no live ``task``/``abort_event`` wired to a
running coroutine — it represents *history*, not an in-flight run.
"""
try:
status = RunStatus(data.get("status") or RunStatus.success.value)
except ValueError:
status = RunStatus.success
return RunRecord(
run_id=data["run_id"],
thread_id=data["thread_id"],
assistant_id=data.get("assistant_id"),
status=status,
# Disconnect mode is irrelevant for a store-only record (no task to
# cancel); ``continue_`` keeps sse_consumer's finally block a no-op.
on_disconnect=DisconnectMode.continue_,
multitask_strategy=data.get("multitask_strategy", "reject"),
metadata=data.get("metadata") or {},
kwargs=data.get("kwargs") or {},
created_at=data.get("created_at") or "",
updated_at=data.get("updated_at") or "",
error=data.get("error"),
)
async def get_persisted(self, run_id: str) -> RunRecord | None:
"""Load a run from the backing store (process-restart fallback).
The in-memory registry is wiped on every Gateway restart, but the
``RunStore`` row survives. When :meth:`get` misses, callers fall back
here so a stale ``joinStream``/``GET run`` can report the run's final
status instead of 404ing. Returns ``None`` when there is no store, no
matching row, or the lookup fails (e.g. no user context).
"""
if self._store is None:
return None
try:
data = await self._store.get(run_id)
except Exception:
logger.warning("Failed to load run %s from store", run_id, exc_info=True)
return None
if not data:
return None
return self._record_from_store(data)
async def list_by_thread(self, thread_id: str) -> list[RunRecord]:
"""Return all runs for a given thread, newest first."""
async with self._lock:
# Dict insertion order matches creation order, so reversing it gives
# us deterministic newest-first results even when timestamps tie.
return [r for r in self._runs.values() if r.thread_id == thread_id]
async def set_status(self, run_id: str, status: RunStatus, *, error: str | None = None) -> None:
"""Transition a run to a new status."""
async with self._lock:
record = self._runs.get(run_id)
if record is None:
logger.warning("set_status called for unknown run %s", run_id)
return
record.status = status
record.updated_at = _now_iso()
if error is not None:
record.error = error
if self._store is not None:
try:
await self._store.update_status(run_id, status.value, error=error)
except Exception:
logger.warning("Failed to persist status update for run %s", run_id, exc_info=True)
logger.info("Run %s -> %s", run_id, status.value)
async def cancel(self, run_id: str, *, action: str = "interrupt") -> bool:
"""Request cancellation of a run.
Args:
run_id: The run ID to cancel.
action: "interrupt" keeps checkpoint, "rollback" reverts to pre-run state.
Sets the abort event with the action reason and cancels the asyncio task.
Returns ``True`` if the run was in-flight and cancellation was initiated.
"""
async with self._lock:
record = self._runs.get(run_id)
if record is None:
return False
if record.status not in (RunStatus.pending, RunStatus.running):
return False
record.abort_action = action
record.abort_event.set()
if record.task is not None and not record.task.done():
record.task.cancel()
record.status = RunStatus.interrupted
record.updated_at = _now_iso()
logger.info("Run %s cancelled (action=%s)", run_id, action)
return True
async def create_or_reject(
self,
thread_id: str,
assistant_id: str | None = None,
*,
on_disconnect: DisconnectMode = DisconnectMode.cancel,
metadata: dict | None = None,
kwargs: dict | None = None,
multitask_strategy: str = "reject",
model_concurrency_limit: tuple[str, int] | None = None,
) -> RunRecord:
"""Atomically check for inflight runs and create a new one.
For ``reject`` strategy, raises ``ConflictError`` if thread
already has a pending/running run. For ``interrupt``/``rollback``,
cancels inflight runs before creating.
This method holds the lock across both the check and the insert,
eliminating the TOCTOU race in separate ``has_inflight`` + ``create``.
"""
run_id = str(uuid.uuid4())
now = _now_iso()
_supported_strategies = ("reject", "interrupt", "rollback")
async with self._lock:
if multitask_strategy not in _supported_strategies:
raise UnsupportedStrategyError(f"Multitask strategy '{multitask_strategy}' is not yet supported. Supported strategies: {', '.join(_supported_strategies)}")
inflight = [r for r in self._runs.values() if r.thread_id == thread_id and r.status in (RunStatus.pending, RunStatus.running)]
if multitask_strategy == "reject" and inflight:
raise ConflictError(f"Thread {thread_id} already has an active run")
if multitask_strategy in ("interrupt", "rollback") and inflight:
for r in inflight:
r.abort_action = multitask_strategy
r.abort_event.set()
if r.task is not None and not r.task.done():
r.task.cancel()
r.status = RunStatus.interrupted
r.updated_at = now
logger.info(
"Cancelled %d inflight run(s) on thread %s (strategy=%s)",
len(inflight),
thread_id,
multitask_strategy,
)
if model_concurrency_limit is not None:
model_name, limit = model_concurrency_limit
active_for_model = sum(1 for r in self._runs.values() if r.status in (RunStatus.pending, RunStatus.running) and str((r.metadata or {}).get("model_name") or "").strip() == model_name)
if active_for_model >= max(1, int(limit)):
raise ModelConcurrencyLimitError(model_name, limit)
record = RunRecord(
run_id=run_id,
thread_id=thread_id,
assistant_id=assistant_id,
status=RunStatus.pending,
on_disconnect=on_disconnect,
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
created_at=now,
updated_at=now,
)
self._runs[run_id] = record
await self._persist_to_store(record)
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
return record
async def has_inflight(self, thread_id: str) -> bool:
"""Return ``True`` if *thread_id* has a pending or running run."""
async with self._lock:
return any(r.thread_id == thread_id and r.status in (RunStatus.pending, RunStatus.running) for r in self._runs.values())
async def list_active(self) -> list[RunRecord]:
"""Return every in-flight run (status pending/running), oldest first.
Powers the admin concurrency monitor: each record represents one live
call into the model runtime (interactive chat, agent chat, roundtable
seat, …), so ``len(list_active())`` is the current concurrent-call count
and each record can be cancelled to relieve model pressure. Completed
records linger in ``_runs`` until :meth:`cleanup`, so we filter by status
rather than returning the whole registry.
"""
async with self._lock:
# Dict insertion order == creation order, so this is oldest-first.
return [r for r in self._runs.values() if r.status in (RunStatus.pending, RunStatus.running)]
async def cleanup(self, run_id: str, *, delay: float = 300) -> None:
"""Remove a run record after an optional delay."""
if delay > 0:
await asyncio.sleep(delay)
async with self._lock:
self._runs.pop(run_id, None)
logger.debug("Run record %s cleaned up", run_id)
class ConflictError(Exception):
"""Raised when multitask_strategy=reject and thread has inflight runs."""
class ModelConcurrencyLimitError(Exception):
"""Raised when a model-specific in-flight run limit has been reached."""
def __init__(self, model_name: str, limit: int) -> None:
self.model_name = model_name
self.limit = limit
super().__init__(f"Model {model_name} has reached its concurrency limit")
class UnsupportedStrategyError(Exception):
"""Raised when a multitask_strategy value is not yet implemented."""
@@ -15,10 +15,10 @@ internal checkpoint callbacks that are not exposed in the Python public API.
from __future__ import annotations
import asyncio
import copy
import inspect
import logging
import asyncio
import copy
import inspect
import logging
from dataclasses import dataclass, field
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Literal, cast
@@ -28,17 +28,31 @@ from langgraph.checkpoint.base import empty_checkpoint
if TYPE_CHECKING:
from langchain_core.messages import HumanMessage
from deerflow.config.app_config import AppConfig
from deerflow.runtime.serialization import serialize
from deerflow.runtime.stream_bridge import StreamBridge
from deerflow.agents.middlewares.title_middleware import (
finalize_thread_title,
log_title_debug,
)
from deerflow.config.app_config import AppConfig
from deerflow.runtime.serialization import serialize
from deerflow.runtime.stream_bridge import StreamBridge
from .manager import RunManager, RunRecord
from .schemas import RunStatus
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Valid stream_mode values for LangGraph's graph.astream()
_VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"}
_VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"}
def _log_background_title_finalize_failure(thread_id: str):
def _done(task: asyncio.Task[bool]) -> None:
try:
task.result()
except Exception:
logger.debug("Failed to finalize background title for thread %s", thread_id, exc_info=True)
return _done
def _build_runtime_context(
@@ -282,13 +296,25 @@ async def run_agent(
# 起新一轮。Pregel 检查到 Command 会从最近 checkpoint 处恢复,而不是重头跑。
stream_input: Any = graph_input
if command is not None:
from langgraph.types import Command as _LGCommand
# 有 resume/update/goto(如 AI 写作 4 个干预卡的 Command(resume=user_input))→
# 构造 langgraph.types.Command,Pregel 从 interrupt 处恢复。
# **空 command(无任何指令)= "从最近 checkpoint 继续执行挂起的任务"**:AI 写作
# "暂停后继续" —— 暂停时硬取消了活跃节点(非 interrupt),continue 要让该阶段节点
# 从 checkpoint 续跑。此时必须用 **None 输入**让 Pregel resume pending tasks;
# 既不能用 graph_input={}(空 dict 被当新一轮输入 → 从图入口 intent 重跑),也不能
# 构造 Command()(没有挂起的 interrupt 可消费,LangGraph 内部会抛
# "cannot access local variable 'resume_is_map'")。
has_directive = any(command.get(k) is not None for k in ("resume", "update", "goto"))
if has_directive:
from langgraph.types import Command as _LGCommand
stream_input = _LGCommand(
resume=command.get("resume"),
update=command.get("update"),
goto=command.get("goto"),
)
stream_input = _LGCommand(
resume=command.get("resume"),
update=command.get("update"),
goto=command.get("goto"),
)
else:
stream_input = None
if len(lg_modes) == 1 and not stream_subgraphs:
# Single mode, no subgraphs: astream yields raw chunks
@@ -388,18 +414,47 @@ async def run_agent(
except Exception:
logger.warning("Failed to persist run completion for %s (non-fatal)", run_id, exc_info=True)
# Sync title from checkpoint to threads_meta.display_name
if checkpointer is not None and thread_store is not None:
try:
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
if ckpt_tuple is not None:
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
title = ckpt.get("channel_values", {}).get("title")
if title:
await thread_store.update_display_name(thread_id, title)
except Exception:
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
# Sync title from checkpoint to threads_meta.display_name
if checkpointer is not None and thread_store is not None:
try:
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
if ckpt_tuple is not None:
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
channel_values = ckpt.get("channel_values", {}) or {}
title = channel_values.get("title")
if title:
await thread_store.update_display_name(thread_id, title)
if (
record.status == RunStatus.success
and channel_values.get("title_provisional") is True
):
log_title_debug(
"Run %s: scheduling background title generation for thread %s",
run_id,
thread_id,
)
title_task = asyncio.create_task(
finalize_thread_title(
checkpointer=checkpointer,
thread_store=thread_store,
thread_id=thread_id,
source_state=copy.deepcopy(channel_values),
app_config=ctx.app_config,
),
name=f"title-finalize:{thread_id}",
)
title_task.add_done_callback(_log_background_title_finalize_failure(thread_id))
elif record.status == RunStatus.success:
log_title_debug(
"Run %s: background title generation not scheduled for thread %s because title_provisional=%r title=%r",
run_id,
thread_id,
channel_values.get("title_provisional"),
channel_values.get("title"),
)
except Exception:
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
# Update threads_meta status based on run outcome
if thread_store is not None:
@@ -22,10 +22,10 @@ from langgraph.checkpoint.base import empty_checkpoint
from deerflow.config.agents_config import AGENT_ID_PATTERN, load_agent_config, load_agent_soul
from deerflow.config.paths import get_paths
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.scheduler import html_page as html_page_utils
from deerflow.runtime.scheduler import template_fill
from deerflow.runtime.scheduler.element import ElementDeliveryClient
from deerflow.runtime.serialization import serialize_channel_values
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.time import now_iso
@@ -184,6 +184,36 @@ TEMPLATES: tuple[TemplateSpec, ...] = (
# 当前引擎只支持 const VAR= 行注入,故暂注册为只展示;接转换需扩展元素注入。
convertible=False,
),
TemplateSpec(
# 显示 no.8;id 用 no8b(no8 已被显示名 no.12 的模板占用)。
id="no8b",
name="no.8",
description="",
file="no8b/no8b.html",
convertible=False,
),
TemplateSpec(
# 显示 no.9;id 用 no9b(no9 已被显示名 no.13 的模板占用)。
id="no9b",
name="no.9",
description="",
file="no9b/no9b.html",
convertible=False,
),
TemplateSpec(
id="no10",
name="no.10",
description="",
file="no10/no10.html",
convertible=False,
),
TemplateSpec(
id="no11",
name="no.11",
description="",
file="no11/no11.html",
convertible=False,
),
TemplateSpec(
id="no8",
name="no.12",
@@ -202,6 +232,41 @@ TEMPLATES: tuple[TemplateSpec, ...] = (
# 故暂注册为只展示;接转换需放宽行注入正则。
convertible=False,
),
TemplateSpec(
id="no14",
name="no.14",
description="",
file="no14/no14.html",
convertible=False,
),
TemplateSpec(
id="no15",
name="no.15",
description="",
file="no15/no15.html",
convertible=False,
),
TemplateSpec(
id="no16",
name="no.16",
description="",
file="no16/no16.html",
convertible=False,
),
TemplateSpec(
id="no17",
name="no.17",
description="",
file="no17/no17.html",
convertible=False,
),
TemplateSpec(
id="no18",
name="no.18",
description="",
file="no18/no18.html",
convertible=False,
),
)
_TEMPLATES_BY_ID: dict[str, TemplateSpec] = {t.id: t for t in TEMPLATES}
@@ -0,0 +1,61 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>台方向“解放军无法战胜”叙事专题数据库</title><style>
:root{
--bg:#fbf3ef;--paper:#fffdfb;--paper2:#fff7f2;--ink:#271312;--muted:#7b6760;
--red:#b92e27;--red2:#8e211c;--red3:#d35142;--line:#ecd6cc;--soft:#f6e6df;
--shadow:0 14px 35px rgba(78,30,18,.12);--radius:22px;
}
*{box-sizing:border-box}html,body{margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",Arial,sans-serif;background:linear-gradient(180deg,#fff 0,#fbf1ec 240px,#fffaf7 100%);color:var(--ink);}
.hero{background:linear-gradient(135deg,var(--red2),var(--red3));color:#fff;padding:30px 40px 34px;box-shadow:0 8px 30px rgba(104,23,17,.22)}
.hero h1{margin:0 0 14px;font-size:34px;line-height:1.22;letter-spacing:.5px}.hero p{margin:0;max-width:1280px;font-size:18px;line-height:1.75;opacity:.96}.meta-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:18px}.meta-pill{border:1px solid rgba(255,255,255,.42);background:rgba(255,255,255,.12);padding:8px 14px;border-radius:999px;font-weight:700}
main{max-width:1520px;margin:0 auto;padding:22px 24px 50px}.tabs-wrap{position:sticky;top:0;z-index:20;background:rgba(251,243,239,.94);border-bottom:1px solid var(--line);backdrop-filter:blur(10px);margin:0 -24px 20px;padding:14px 24px 12px;overflow:auto}.tabs{display:flex;gap:10px;min-width:max-content}.tab-btn{appearance:none;border:1px solid var(--line);background:white;color:var(--red2);border-radius:999px;padding:11px 16px;font-weight:900;cursor:pointer;box-shadow:0 6px 14px rgba(102,38,26,.06);white-space:nowrap}.tab-btn small{color:inherit;opacity:.72;margin-left:5px}.tab-btn.active{background:var(--red2);color:#fff;border-color:var(--red2)}
.toolbar{display:grid;grid-template-columns:1fr 220px 210px 170px;gap:14px;align-items:center;background:rgba(255,255,255,.88);border:1px solid var(--line);border-radius:20px;padding:14px;margin-bottom:22px;box-shadow:var(--shadow)}.searchbox{height:54px;border:1px solid var(--line);border-radius:16px;background:white;display:flex;align-items:center;overflow:hidden}.searchbox span{width:78px;text-align:center;color:var(--red);font-weight:900;font-size:22px;letter-spacing:4px}.searchbox input{border:0;outline:0;flex:1;font-size:17px;padding:0 18px;background:transparent;color:var(--ink)}select,.btn{height:54px;border:1px solid var(--line);border-radius:16px;background:white;padding:0 16px;font-size:16px;color:var(--ink)}.btn{font-weight:900;cursor:pointer}.btn.primary{background:var(--red);color:#fff;border-color:var(--red)}
.section-intro,.panel{background:rgba(255,255,255,.9);border:1px solid var(--line);border-radius:var(--radius);padding:22px;margin-bottom:20px;box-shadow:var(--shadow)}.intro-head{display:flex;gap:22px;justify-content:space-between}.intro-head h2{margin:0 0 8px;color:var(--red2);font-size:26px}.intro-head p{margin:0 0 8px;color:var(--muted);line-height:1.7}.stats{display:flex;gap:10px;min-width:270px}.stat{flex:1;background:var(--paper2);border:1px solid var(--line);border-radius:16px;padding:12px;text-align:center}.stat b{display:block;font-size:26px;color:var(--red)}.stat span{font-size:13px;color:var(--muted)}.profile-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:18px}.profile-card{background:#fff;border:1px solid var(--line);border-radius:16px;padding:16px}.profile-card b{display:block;color:var(--red2);margin-bottom:8px}.profile-card p,.profile-card li{color:var(--muted);line-height:1.65;margin:0}.profile-card ul{margin:0;padding-left:18px}.chipbar{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 18px}.chip{border:1px solid var(--line);background:white;border-radius:999px;padding:7px 12px;cursor:pointer;color:var(--red2);font-weight:800}.chip.active{background:var(--red2);color:white;border-color:var(--red2)}.result-note,.footer-note{background:#fff8f4;border:1px solid var(--line);border-radius:16px;padding:12px 16px;color:var(--muted);line-height:1.7;margin-bottom:18px}.footer-note{margin-top:20px}
.card-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.record-card,.chain-card{background:#fff;border:1px solid var(--line);border-radius:22px;padding:18px;box-shadow:0 10px 25px rgba(78,30,18,.09);display:flex;flex-direction:column;gap:12px}.record-card h3,.chain-card h3{margin:0;color:var(--red2);font-size:21px}.record-card p,.chain-card p{margin:0;color:var(--muted);line-height:1.7}.badge{display:inline-block;background:#f8e4dc;color:var(--red2);border:1px solid var(--line);border-radius:999px;padding:5px 10px;font-weight:900;font-size:13px}.field-list{display:grid;gap:8px}.mini-kv{border-left:4px solid var(--red3);background:#fff8f4;border-radius:10px;padding:9px 10px}.mini-kv b{display:block;color:var(--red2);margin-bottom:4px}.mini-kv span{color:var(--muted);line-height:1.55}.tags{display:flex;gap:7px;flex-wrap:wrap}.tag,.source-pill{font-size:12px;border-radius:999px;padding:5px 9px;background:#f5eee9;color:#61423b;border:1px solid var(--line)}.source-pill{background:#fff2e8;color:#9a3a2f}.card-actions{margin-top:auto;display:flex;justify-content:flex-end}.open-btn{border:0;background:var(--red);color:white;border-radius:12px;padding:10px 14px;font-weight:900;cursor:pointer}.empty{padding:40px;text-align:center;background:#fff;border:1px dashed var(--line);border-radius:20px;color:var(--muted)}
.timeline{position:relative;margin-left:16px}.timeline:before{content:"";position:absolute;left:14px;top:0;bottom:0;width:3px;background:var(--line)}.timeline-item{position:relative;margin:0 0 16px 42px}.timeline-item:before{content:"";position:absolute;left:-34px;top:26px;width:15px;height:15px;border-radius:50%;background:var(--red);box-shadow:0 0 0 6px #f9e7df}.chain-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}.chain-steps{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.step{background:#fff5ef;border:1px solid var(--line);border-radius:12px;padding:9px 10px;color:var(--red2);font-weight:800}.arrow{color:var(--red);font-weight:900}.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:18px;background:#fff}table{width:100%;border-collapse:collapse;min-width:900px}th,td{padding:13px 14px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left;line-height:1.55}th{background:#fff1eb;color:var(--red2);position:sticky;top:0;z-index:1}tr:hover td{background:#fffaf7}.cell-title{font-weight:900;color:var(--red2)}
.model-grid,.source-grid,.detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.model-card,.source-card,.kv{background:#fff;border:1px solid var(--line);border-radius:16px;padding:15px}.model-card b,.source-card b,.kv b{display:block;color:var(--red2);margin-bottom:7px}.model-card li,.source-card p,.source-card span,.kv span{color:var(--muted);line-height:1.65}.source-card a,.source-line a{color:var(--red);font-weight:900;text-decoration:none}.modal-backdrop{display:none;position:fixed;inset:0;background:rgba(30,15,13,.52);z-index:99;align-items:center;justify-content:center;padding:24px}.modal{width:min(1120px,96vw);max-height:90vh;overflow:hidden;background:var(--paper);border-radius:26px;box-shadow:0 20px 70px rgba(0,0,0,.32);border:1px solid var(--line);display:flex;flex-direction:column}.modal-head{background:linear-gradient(135deg,var(--red2),var(--red3));color:#fff;padding:22px 26px;display:flex;justify-content:space-between;gap:18px}.modal-head h2{margin:0 0 8px;font-size:24px}.modal-head p{margin:0;line-height:1.65;opacity:.95}.close{width:42px;height:42px;border-radius:50%;border:1px solid rgba(255,255,255,.4);background:rgba(255,255,255,.16);color:#fff;font-size:28px;cursor:pointer}.modal-body{overflow:auto;padding:22px}.detail-grid{grid-template-columns:repeat(2,1fr);margin:16px 0}.detail-section{background:#fff;border:1px solid var(--line);border-radius:18px;padding:18px;margin:14px 0}.detail-section h3{margin:0 0 10px;color:var(--red2)}.detail-section p,.detail-section li{line-height:1.75;color:var(--muted)}.source-list{display:grid;gap:10px}.source-line{background:#fff8f4;border:1px solid var(--line);border-radius:14px;padding:13px}.source-line p{margin:4px 0;color:var(--muted)}
@media(max-width:1180px){.toolbar{grid-template-columns:1fr 1fr}.card-grid,.profile-grid,.model-grid,.source-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:760px){.hero{padding:24px 18px}.hero h1{font-size:24px}main{padding:14px}.tabs-wrap{margin:0 -14px 14px;padding:12px 14px}.toolbar,.card-grid,.profile-grid,.chain-grid,.model-grid,.source-grid,.detail-grid{grid-template-columns:1fr}.intro-head{flex-direction:column}.stats{min-width:0}.modal-backdrop{padding:8px}}
</style></head><body>
<header class="hero"><h1>台方向“解放军无法战胜”叙事专题数据库</h1><p>围绕“台湾难以抗衡 / 抵抗代价过高 / 外援不确定”的安全叙事,展示事件、口径、党派转译、平台链路、受众画像、青年兵役与疑美叙事。</p><div class="meta-row"><span class="meta-pill">版本:纯中文展示版 v5.0</span><span class="meta-pill">横向 Tab 导航</span><span class="meta-pill">无可见 JSON 区块</span><span class="meta-pill">本地单文件可运行</span></div></header>
<main><div class="tabs-wrap"><nav class="tabs" id="tabs"><button class="tab-btn active" data-tab="overview">专题总览 <small>(4)</small></button><button class="tab-btn " data-tab="core_narratives">核心叙事库 <small>(12)</small></button><button class="tab-btn " data-tab="narrative_chains">叙事链条拆解 <small>(6)</small></button><button class="tab-btn " data-tab="timeline">事件时间线 <small>(12)</small></button><button class="tab-btn " data-tab="official_positions">官方口径对照 <small>(9)</small></button><button class="tab-btn " data-tab="party_translation">台湾党派转译 <small>(8)</small></button><button class="tab-btn " data-tab="platform_chains">平台传播链路 <small>(9)</small></button><button class="tab-btn " data-tab="audience_profiles">受众群体画像 <small>(10)</small></button><button class="tab-btn " data-tab="youth_conscription">青年与兵役专题 <small>(9)</small></button><button class="tab-btn " data-tab="us_skepticism">疑美叙事专题 <small>(7)</small></button></nav></div><section class="toolbar"><label class="searchbox"><span>搜索</span><input id="searchInput" placeholder="输入关键词:军演、兵役、疑美、Dcard、封锁、青年……"></label><select id="tagSelect"><option value="">全部标签</option></select><select id="viewSelect"><option value="rich">丰富卡片</option><option value="table">表格视图</option></select><button class="btn primary" id="exportBtn">导出当前表格</button></section><section id="content"><section class="section-intro"><div class="intro-head"><div><h2>专题总览</h2><p>定义研究对象、时间范围、边界、资料来源和系统结构。</p><p class="result-note">本页将底层数据转为中文卡片、时间线、链条图、表格和详情面板,不显示 JSON 原文。</p></div><div class="stats"><div class="stat"><b>4</b><span>条目</span></div><div class="stat"><b>8</b><span>标签</span></div><div class="stat"><b>3</b><span>来源引用</span></div></div></div><div class="profile-grid"><div class="profile-card"><b>研究重点</b><p>给出专题边界、研究对象、数据层构成、平台环境基线与安全边界。</p></div><div class="profile-card"><b>展示方式</b><p>总览卡片、统计面板、叙事—心理—行为模型、来源索引。</p></div><div class="profile-card"><b>适合回答</b><ul><li>这个专题研究什么?</li><li>数据有哪些层次?</li><li>哪些内容可以直接入库?</li><li>有哪些伦理边界?</li></ul></div><div class="profile-card"><b>核心字段</b><ul><li>研究对象</li><li>时间范围</li><li>研究边界</li><li>主要社群平台</li><li>数据结构</li></ul></div></div></section><div class="result-note">当前显示 <b>4</b> / 4 条记录。点击“查看完整详情”可打开中文详情面板。</div><div class="card-grid"><article class="record-card"><div><span class="badge">OV001 · 专题总览</span></div><h3>专题定义</h3><p>研究台湾方向围绕“解放军无法战胜 / 台湾难以抗衡 / 抵抗代价过高”的安全叙事体系。</p><div class="field-list"><div class="mini-kv"><b>研究对象</b><span>台湾舆论场中的安全叙事、党派转译、平台传播、受众心理与社会反应</span></div><div class="mini-kv"><b>时间范围</b><span>1995—1996;2022—2026为重点</span></div><div class="mini-kv"><b>研究边界</b><span>公开资料研究,不做隐蔽影响、定向操控、恐吓传播或虚假信息设计</span></div></div><div class="tags"><span class="tag">研究对象</span><span class="tag">范围</span></div><div class="card-actions"><button class="open-btn primary" data-open="OV001">查看完整详情</button></div></article><article class="record-card"><div><span class="badge">OV002 · 专题总览</span></div><h3>数据层说明</h3><p>本库包括10个栏目、12类核心叙事、6条叙事链、12个事件/战术动作条目、9类平台链路和10类受众画像。</p><div class="field-list"><div class="mini-kv"><b>栏目数量</b><span>10</span></div><div class="mini-kv"><b>核心叙事</b><span>12</span></div><div class="mini-kv"><b>叙事链</b><span>6</span></div><div class="mini-kv"><b>事件条目</b><span>12</span></div></div><div class="tags"><span class="tag">数据结构</span><span class="tag">知识库</span></div><div class="card-actions"><button class="open-btn primary" data-open="OV002">查看完整详情</button></div></article><article class="record-card"><div><span class="badge">OV003 · 专题总览</span></div><h3>安全与伦理边界</h3><p>本系统定位为研究型工具,输出为公开舆情研究、风险沟通和信息韧性分析。</p><div class="field-list"><div class="mini-kv"><b>允许方向</b><span>公开舆情研究、平台机制分析、风险沟通、政策研究</span></div><div class="mini-kv"><b>禁止方向</b><span>隐蔽投放、心理操控、恐吓传播、谣言制造、群体污名化、军事攻击Line truncated
<div class="modal-backdrop" id="modalBackdrop"><div class="modal"><div class="modal-head"><div><h2 id="modalTitle"></h2><p id="modalSummary"></p></div><button class="close" id="closeBtn">×</button></div><div class="modal-body" id="modalBody"></div></div></div>
<script type="application/json" id="app-data">{"metadata":{"title":"台方向“解放军无法战胜”叙事专题数据库","subtitle":"围绕“台湾难以抗衡 / 抵抗代价过高 / 外援不确定”的安全叙事,展示事件、口径、党派转译、平台链路、受众画像、青年兵役与疑美叙事。","version":"1.0.0","language":"zh-CN","created_at":"2026-06-25T11:56:25","data_date":"2026-06-25","scope":"公开资料研究、叙事结构分析、平台链路与受众心理研究","ethics_boundary":"仅用于透明、非操纵、非煽动的研究型分析;不用于隐蔽影响、恐吓传播、群体污名化或虚假信息设计。","display_version":"Rich UI v3.1","ui_note":"横向Tab、中文卡片、字段网格、时间线、链条图、详情面板、来源说明。"},"stats":{"tab_count":10,"record_count":86,"source_count":16,"last_updated":"2026-06-25","current_timezone":"Asia/Tokyo","data_status":{"framework":"complete","event_cards":"initial_completed","official_raw_quotes":"needs_more_original_text","platform_samples":"schema_ready_needs_collection","poll_data":"partial","front_end":"single_page_offline"}},"tabs":[{"id":"overview","name":"专题总览","description":"定义研究对象、时间范围、边界、资料来源和系统结构。","records":[{"id":"OV001","title":"专题定义","summary":"研究台湾方向围绕“解放军无法战胜 / 台湾难以抗衡 / 抵抗代价过高”的安全叙事体系。","category":"专题总览","tags":["研究对象","范围"],"level":"研究条目","fields":{"研究对象":"台湾舆论场中的安全叙事、党派转译、平台传播、受众心理与社会反应","时间范围":"1995—1996;2022—2026为重点","研究边界":"公开资料研究,不做隐蔽影响、定向操控、恐吓传播或虚假信息设计"},"detail_sections":[{"title":"核心问题","bullets":["该叙事如何由军演、军售、领导人讲话等事件激活?","它如何被台湾内部政党、媒体、政论节目和社群平台转译?","它如何与兵役焦虑、疑美论、棋子论、乌克兰化和避战论结合?","哪些受众更易将其转化为政治态度、家庭风险或个人规划问题?"]},{"title":"入库原则","bullets":["事实、观点、推测分开记录。","官方来源、民调、智库、媒体、社群样本分级标注。","平台语料只做公开研究与机制分析,不制作操控性传播内容。"]}],"source_refs":[],"display_type":"overview","中文展示摘要":"专题定义:研究台湾方向围绕“解放军无法战胜 / 台湾难以抗衡 / 抵抗代价过高”的安全叙事体系。"},{"id":"OV002","title":"数据层说明","summary":"本库包括10个栏目、12类核心叙事、6条叙事链、12个事件/战术动作条目、9类平台链路和10类受众画像。","category":"专题总览","tags":["数据结构","知识库"],"level":"研究条目","fields":{"栏目数量":10,"核心叙事":12,"叙事链":6,"事件条目":12,"平台类型":9,"受众类型":10},"detail_sections":[{"title":"可前端展示功能","bullets":["Tab切换","全库检索","栏目内筛选","详情弹框","来源查看","结构化详情查看","导出当前栏目表格"]}],"source_refs":[],"display_type":"overview","中文展示摘要":"数据层说明:本库包括10个栏目、12类核心叙事、6条叙事链、12个事件/战术动作条目、9类平台链路和10类受众画像。"},{"id":"OV003","title":"安全与伦理边界","summary":"本系统定位为研究型工具,输出为公开舆情研究、风险沟通和信息韧性分析。","category":"专题总览","tags":["安全边界","研究用途"],"level":"研究条目","fields":{"允许方向":"公开舆情研究、平台机制分析、风险沟通、政策研究","禁止方向":"隐蔽投放、心理操控、恐吓传播、谣言制造、群体污名化、军事攻击细节"},"detail_sections":[{"title":"研究注意事项","bullets":["不得把单一平台声量直接等同于台湾社会整体民意。","不得把青年对兵役不满直接等同于没有防卫意志。","不得把政党表态当成群体共识。","涉及社群样本时应保留来源、时间和上下文。"]}],"source_refs":[],"display_type":"overview","中文展示摘要":"安全与伦理边界:本系统定位为研究型工具,输出为公开舆情研究、风险沟通和信息韧性分析。"},{"id":"OV004","title":"数字与平台环境基线","summary":"台湾互联网和社群普及率很高,YouTube、Facebook、Instagram、Dcard、Threads、PTT 等平台承担不同传播功能。","category":"专题总览","tags":["平台环境","基线数据"],"level":"研究条目","fields":{"2025社群用户身份":"约1840万,占总人口约79.4%","2025年底互联网用户":"约2230万,普及率约96.7%","主要社群平台":"YouTube、Facebook、Instagram、Dcard、Threads、PTT、LINE"},"detail_sections":[{"title":"平台环境解释","bullets":["高互Line truncated
<script>
'use strict';
const DATA = JSON.parse(document.getElementById('app-data').textContent);
const $ = s => document.querySelector(s);
const arr = x => Array.isArray(x) ? x : (x ? [x] : []);
const state = { tab: DATA.tabs?.[0]?.id || '', q: '', tag: '', view: 'rich' };
function esc(v) { return String(v ?? '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
function textOf(v) { if (v === null || v === undefined) return ''; if (Array.isArray(v)) return v.map(textOf).join('、'); if (typeof v === 'object') return Object.entries(v).map(([k,val]) => k + ':' + textOf(val)).join(';'); return String(v); }
function allText(obj) { return textOf(obj).toLowerCase(); }
function getTab() { return DATA.tabs.find(t => t.id === state.tab) || DATA.tabs[0]; }
function getSource(id) { return (DATA.sources || []).find(s => s.id === id); }
function tagHtml(tags) { return arr(tags).map(t => `<span class="tag">${esc(t)}</span>`).join(''); }
function sourceHtml(ids) { return arr(ids).map(id => `<span class="source-pill">来源 ${esc(id)}</span>`).join(''); }
function list(items) { return arr(items).map(x => `<li>${esc(x)}</li>`).join(''); }
function sourceDetail(ids) { const sources = arr(ids).map(getSource).filter(Boolean); if (!sources.length) return ''; return `<section class="detail-section"><h3>来源说明</h3><div class="source-list">${sources.map(s => `<div class="source-line"><b>${esc(s.id)} · ${esc(s.title)}</b><p>${esc(s.publisher)}|${esc(s.date)}|可信度:${esc(s.reliability)}</p><p>${esc(s.note || '')}</p><p><a href="${esc(s.url)}" target="_blank" rel="noopener">打开来源</a></p></div>`).join('')}</div></section>`; }
function renderIntro(tab) { const p = tab.profile || {}; return `<section class="section-intro"><div class="intro-head"><div><h2>${esc(tab.name)}</h2><p>${esc(tab.description || tab.richness_note || '')}</p><p class="result-note">本页将底层数据转为中文卡片、时间线、链条图、表格和详情面板,不显示 JSON 原文。</p></div><div class="stats"><div class="stat"><b>${tab.records.length}</b><span>条目</span></div><div class="stat"><b>${tab.tag_count || 0}</b><span>标签</span></div><div class="stat"><b>${tab.source_ref_count || 0}</b><span>来源引用</span></div></div></div><div class="profile-grid"><div class="profile-card"><b>研究重点</b><p>${esc(p['研究重点'] || '本栏目提供可结构化入库的数据条目。')}</p></div><div class="profile-card"><b>展示方式</b><p>${esc(p['展示方式'] || '中文字段、详情面板、来源说明。')}</p></div><div class="profile-card"><b>适合回答</b><ul>${list(p['适合回答'] || [])}</ul></div><div class="profile-card"><b>核心字段</b><ul>${list(p['核心字段'] || [])}</ul></div></div></section>`; }
function fieldRows(r, max=4) { const entries = Object.entries(r.fields || {}).slice(0, max); return entries.length ? entries.map(([k,v]) => `<div class="mini-kv"><b>${esc(k)}</b><span>${esc(textOf(v))}</span></div>`).join('') : '<div class="mini-kv"><b>说明</b><span>该条目暂无额外字段,完整信息见详情面板。</span></div>'; }
function cardHtml(r) { return `<article class="record-card"><div><span class="badge">${esc(r.id)} · ${esc(r.category || r.level || '条目')}</span></div><h3>${esc(r.title)}</h3><p>${esc(r.summary || '')}</p><div class="field-list">${fieldRows(r,4)}</div><div class="tags">${tagHtml(r.tags)}${sourceHtml(r.source_refs)}</div><div class="card-actions"><button class="open-btn primary" data-open="${esc(r.id)}">查看完整详情</button></div></article>`; }
function renderCards(records) { return records.length ? `<div class="card-grid">${records.map(cardHtml).join('')}</div>` : '<div class="empty">没有匹配的记录。请调整搜索词或标签。</div>'; }
function renderTimeline(records) { return records.length ? `<div class="timeline">${records.map(r => `<div class="timeline-item">${cardHtml(r)}</div>`).join('')}</div>` : '<div class="empty">没有匹配的时间线记录。</div>'; }
function renderChains(records) { if (!records.length) return '<div class="empty">没有匹配的叙事链条。</div>'; return `<div class="chain-grid">${records.map(r => { const chain = (r.fields && r.fields['叙事链条']) ? String(r.fields['叙事链条']).split('→').map(s => s.trim()).filter(Boolean) : []; return `<article class="chain-card"><span class="badge">${esc(r.id)}</span><h3>${esc(r.title)}</h3><p>${esc(r.summary || '')}</p><div class="chain-steps">${chain.map((s,i) => `<span class="step">${esc(s)}</span>${i < chain.length - 1 ? '<span class="arrow">→</span>' : ''}`).join('')}</div><div class="field-list">${fieldRows(r,4)}</div><div class="tags">${tagHtml(r.tags)}${sourceHtml(r.source_refs)}</div><div class="card-actions"><button class="open-btn primary" data-open="${esc(r.id)}">查看完整详情</button></div></article>`; }).join('')}</div>`; }
function renderTable(records) { if (!records.length) return '<div class="empty">没有匹配记录。</div>'; return `<div class="table-wrap"><table><thead><tr><th>ID</th><th>标题</th><th>摘要</th><th>主要字段</th><th>标签/来源</th><th>操作</th></tr></thead><tbody>${records.map(r => `<tr><td><span class="badge">${esc(r.id)}</span></td><td class="cell-title">${esc(r.title)}</td><td>${esc(r.summary || '')}</td><td>${Object.entries(r.fields || {}).slice(0,6).map(([k,v]) => `<b>${esc(k)}:</b>${esc(textOf(v))}`).join('<br>')}</td><td>${tagHtml(r.tags)}<br>${sourceHtml(r.source_refs)}</td><td><button class="open-btn primary" data-open="${esc(r.id)}">详情</button></td></tr>`).join('')}</tbody></table></div>`; }
function renderModels() { if (state.tab !== 'overview') return ''; const m = DATA.models || {}; return `<section class="panel"><h3>叙事 → 心理 → 行为模型</h3><div class="model-grid"><div class="model-card"><b>叙事到心理</b><ul>${arr(m.narrative_to_psychology).map(x => `<li>${esc(x.narrative)} → ${esc(x.psychology)}</li>`).join('')}</ul></div><div class="model-card"><b>心理到行为</b><ul>${arr(m.psychology_to_behavior).map(x => `<li>${esc(x.psychology)} → ${esc(x.behavior)}</li>`).join('')}</ul></div><div class="model-card"><b>可观测行为指标</b><ul>${arr(m.observable_behavior_indicators).map(x => `<li>${esc(x)}</li>`).join('')}</ul></div></div></section>`; }
function renderSourcesPanel(tab) { const ids = [...new Set(tab.records.flatMap(r => arr(r.source_refs)))]; const sources = (ids.length ? ids.map(getSource).filter(Boolean) : (DATA.sources || []).slice(0,8)); if (!sources.length) return ''; return `<section class="panel"><h3>来源索引</h3><div class="source-grid">${sources.map(s => `<div class="source-card"><b>${esc(s.id)} · ${esc(s.title)}</b><span>${esc(s.publisher)}|${esc(s.date)}|可信度:${esc(s.reliability)}</span><p>${esc(s.note || '')}</p><p><a href="${esc(s.url)}" target="_blank" rel="noopener">打开来源</a></p></div>`).join('')}</div></section>`; }
function filterRecords(tab) { return tab.records.filter(r => (!state.q || allText(r).includes(state.q)) && (!state.tag || arr(r.tags).includes(state.tag))); }
function buildTagOptions(tab) { const tags = [...new Set(tab.records.flatMap(r => arr(r.tags)))].sort(); $('#tagSelect').innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join(''); }
function renderChips(tab) { const tags = [...new Set(tab.records.flatMap(r => arr(r.tags)))].slice(0, 24); $('#chips').innerHTML = `<button class="chip ${state.tag === '' ? 'active' : ''}" data-tag="">全部</button>` + tags.map(t => `<button class="chip ${state.tag === t ? 'active' : ''}" data-tag="${esc(t)}">${esc(t)}</button>`).join(''); }
function renderRecordsOnly() { const tab = getTab(); const records = filterRecords(tab); let body = ''; if (state.view === 'table') body = renderTable(records); else if (tab.id === 'timeline') body = renderTimeline(records); else if (tab.id === 'narrative_chains') body = renderChains(records); else body = renderCards(records); $('#recordsHost').innerHTML = `<div class="result-note">当前显示 <b>${records.length}</b> / ${tab.records.length} 条记录。点击“查看完整详情”可打开中文详情面板。</div>` + body + renderModels() + renderSourcesPanel(tab) + `<div class="footer-note">后续可继续补充:${esc(arr(DATA.schema_note?.recommended_next_collection).slice(0,7).join(';'))}</div>`; }
function render() { const tab = getTab(); document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === state.tab)); buildTagOptions(tab); $('#tagSelect').value = state.tag; $('#content').innerHTML = renderIntro(tab) + '<div class="chipbar" id="chips"></div><div id="recordsHost"></div>'; renderChips(tab); renderRecordsOnly(); }
function findRecord(id) { for (const t of DATA.tabs) for (const r of t.records) if (r.id === id) return r; }
function sectionHtml(sec) { let h = `<section class="detail-section"><h3>${esc(sec.title || '详情')}</h3>`; if (sec.text) h += `<p>${esc(sec.text)}</p>`; if (sec.bullets) h += `<ul>${arr(sec.bullets).map(b => `<li>${esc(b)}</li>`).join('')}</ul>`; if (sec.table) { const rows = arr(sec.table); const keys = [...new Set(rows.flatMap(o => Object.keys(o)))]; h += `<div class="table-wrap"><table><thead><tr>${keys.map(k => `<th>${esc(k)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${keys.map(k => `<td>${esc(textOf(row[k]))}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`; } return h + '</section>'; }
function openModal(id) { const r = findRecord(id); if (!r) return; $('#modalTitle').textContent = `${r.id} · ${r.title}`; $('#modalSummary').textContent = r.summary || ''; const fields = Object.entries(r.fields || {}).map(([k,v]) => `<div class="kv"><b>${esc(k)}</b><span>${esc(textOf(v))}</span></div>`).join(''); const sections = arr(r.detail_sections).map(sectionHtml).join(''); $('#modalBody').innerHTML = `<div class="tags">${tagHtml(r.tags)}${sourceHtml(r.source_refs)}</div><div class="detail-grid">${fields || '<div class="kv"><b>说明</b><span>该条目暂无额外字段,主要内容已在摘要和下方说明中展示。</span></div>'}</div>${sections || '<section class="detail-section"><h3>说明</h3><p>该条目暂无二级详情,已展示全部可读字段。</p></section>'}${sourceDetail(r.source_refs)}`; $('#modalBackdrop').style.display = 'flex'; }
function closeModal() { $('#modalBackdrop').style.display = 'none'; }
function csvEscape(v) { return '"' + textOf(v).replace(/"/g, '""') + '"'; }
function exportCurrentTabCsv() { const tab = getTab(); const rows = filterRecords(tab); const baseCols = ['id','title','summary','category','tags','source_refs']; const dyn = [...new Set(rows.flatMap(r => Object.keys(r.fields || {})))]; const header = baseCols.concat(dyn); const lines = [header.map(csvEscape).join(',')]; rows.forEach(r => { lines.push(header.map(c => csvEscape(c in r ? (Array.isArray(r[c]) ? r[c].join('、') : r[c]) : (r.fields || {})[c])).join(',')); }); const blob = new Blob(['' + lines.join('\n')], {type:'text/csv;charset=utf-8'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = tab.id + '_records.csv'; a.click(); URL.revokeObjectURL(a.href); }
function init() { $('#tabs').innerHTML = DATA.tabs.map(t => `<button class="tab-btn" data-tab="${esc(t.id)}">${esc(t.name)} <small>(${t.records.length})</small></button>`).join(''); $('#tabs').addEventListener('click', e => { const btn = e.target.closest('.tab-btn'); if (!btn) return; state.tab = btn.dataset.tab; state.tag = ''; state.q = $('#searchInput').value.trim().toLowerCase(); render(); window.scrollTo({top:0,behavior:'smooth'}); }); $('#searchInput').addEventListener('input', e => { state.q = e.target.value.trim().toLowerCase(); renderRecordsOnly(); }); $('#tagSelect').addEventListener('change', e => { state.tag = e.target.value; renderChips(getTab()); renderRecordsOnly(); }); $('#viewSelect').addEventListener('change', e => { state.view = e.target.value; renderRecordsOnly(); }); $('#content').addEventListener('click', e => { const btn = e.target.closest('[data-open]'); if (btn) openModal(btn.dataset.open); const chip = e.target.closest('[data-tag]'); if (chip) { state.tag = chip.dataset.tag; $('#tagSelect').value = state.tag; renderChips(getTab()); renderRecordsOnly(); } }); $('#exportBtn').addEventListener('click', exportCurrentTabCsv); $('#closeBtn').addEventListener('click', closeModal); $('#modalBackdrop').addEventListener('click', e => { if (e.target.id === 'modalBackdrop') closeModal(); }); window.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); render(); }
try { init(); } catch (err) { console.error(err); $('#content').innerHTML = `<div class="empty">页面加载出错:${esc(err.message)}。请使用新版 v5 文件或更换 Chrome / Edge 打开。</div>`; }
</script></body></html>
@@ -0,0 +1,282 @@
<!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 {
--bg0:#5b0d14;
--bg1:#7f1620;
--bg2:#2b080b;
--card:#ffffff;
--ink:#241015;
--muted:#6f5960;
--line:#ead9dc;
--soft:#fff7f8;
--accent:#b6202d;
--accent2:#f2d7dc;
--ok:#1f7a4d;
--warn:#a96b00;
--danger:#b6202d;
--shadow:0 18px 45px rgba(27,5,9,.18);
}
*{box-sizing:border-box}
body{margin:0;font-family:"Microsoft YaHei", "PingFang SC", Arial, sans-serif;background:radial-gradient(circle at 20% 0%, #9e2430 0, var(--bg1) 22%, var(--bg0) 50%, var(--bg2) 100%);color:var(--ink);}
.hero{max-width:1320px;margin:0 auto;padding:34px 24px 18px;color:#fff;}
.hero-top{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;}
.title-block h1{font-size:34px;margin:0 0 10px;letter-spacing:.5px}
.title-block p{margin:0;max-width:900px;line-height:1.75;color:#ffe9ed;font-size:14px}
.version{border:1px solid rgba(255,255,255,.35);padding:10px 14px;border-radius:999px;background:rgba(255,255,255,.1);white-space:nowrap;font-weight:700}
.shell{max-width:1320px;margin:0 auto 40px;padding:0 24px 28px;}
.panel{background:rgba(255,255,255,.96);border:1px solid rgba(255,255,255,.45);border-radius:26px;box-shadow:var(--shadow);overflow:hidden;}
.tabs{display:flex;gap:8px;overflow-x:auto;padding:14px 14px 0;background:linear-gradient(180deg,#fff,#fff8f9);border-bottom:1px solid var(--line);}
.tab-btn{border:1px solid var(--line);background:#fff;color:var(--muted);padding:10px 16px;border-radius:999px;font-weight:700;cursor:pointer;white-space:nowrap;transition:.18s;}
.tab-btn:hover{transform:translateY(-1px);border-color:#d9aab1;color:var(--accent)}
.tab-btn.active{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 8px 18px rgba(182,32,45,.22)}
.content{padding:22px;}
.tab-section{display:none}
.tab-section.active{display:block}
.section-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:16px;}
.section-head h2{margin:0;font-size:24px;color:#2b0c10}
.section-head p{margin:6px 0 0;color:var(--muted);line-height:1.65}
.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin:14px 0 18px;}
.input,.select{height:42px;border:1px solid var(--line);border-radius:14px;padding:0 12px;background:#fff;outline:none;color:var(--ink);}
.input{min-width:260px;flex:1}
.select{min-width:160px}
.stats-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:20px;}
.stat{background:linear-gradient(180deg,#fff,#fff6f7);border:1px solid var(--line);border-radius:20px;padding:16px;}
.stat .value{font-size:26px;font-weight:900;color:var(--accent);margin-bottom:6px}
.stat .label{font-weight:800;margin-bottom:8px}
.stat .note{font-size:12px;color:var(--muted);line-height:1.5}
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;}
.card{background:#fff;border:1px solid var(--line);border-radius:22px;padding:16px;box-shadow:0 10px 26px rgba(58,16,23,.07);position:relative;overflow:hidden;}
.card::before{content:"";position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--accent),#e8a1ab)}
.card h3{margin:4px 0 6px;font-size:19px;color:#2b0c10}
.sub{font-size:13px;color:var(--muted);line-height:1.55;margin-bottom:12px}
.meta-row{display:grid;grid-template-columns:90px 1fr;gap:8px;font-size:13px;margin:8px 0;line-height:1.5}
.meta-row b{color:#5a1b22}
.tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:12px}
.tag{font-size:12px;padding:5px 8px;border-radius:999px;background:var(--soft);border:1px solid var(--accent2);color:#7b2430;font-weight:700}
.risk{position:absolute;right:14px;top:13px;font-size:12px;padding:5px 9px;border-radius:999px;color:#fff;background:var(--warn);font-weight:800}
.risk.high{background:var(--danger)}
.risk.medium{background:var(--warn)}
.risk.low{background:var(--ok)}
.btn{border:0;background:var(--accent);color:#fff;border-radius:12px;padding:10px 12px;font-weight:800;cursor:pointer;margin-top:12px;}
.btn.secondary{background:#fff;color:var(--accent);border:1px solid var(--accent2)}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:18px;background:#fff;}
table{border-collapse:collapse;width:100%;min-width:980px}
th,td{border-bottom:1px solid var(--line);padding:12px 12px;text-align:left;vertical-align:top;line-height:1.55;font-size:13px}
th{background:#fff3f5;color:#5b1118;position:sticky;top:0;z-index:1}
tr:hover td{background:#fffafa}
.timeline{position:relative;margin:8px 0 0;padding-left:24px}
.timeline::before{content:"";position:absolute;left:8px;top:0;bottom:0;width:2px;background:#e7b5bd}
.event{position:relative;margin:0 0 14px;padding:14px 14px 14px 18px;background:#fff;border:1px solid var(--line);border-radius:18px;}
.event::before{content:"";position:absolute;left:-22px;top:20px;width:12px;height:12px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 4px #f7dce1}
.event .year{font-weight:900;color:var(--accent);font-size:14px}
.event h3{margin:4px 0 8px}
.columns{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.list-card{border:1px solid var(--line);border-radius:18px;padding:14px;background:#fff}
.list-card h3{margin:0 0 10px;color:#391016}
.list-card ul{margin:0;padding-left:18px;line-height:1.8;color:#443238}
.schema-box{background:#fff;border:1px solid var(--line);border-radius:18px;padding:14px;margin-bottom:12px}
.schema-title{font-weight:900;color:#5b1118;margin-bottom:10px}
.field-list{display:flex;flex-wrap:wrap;gap:8px}
.field{padding:7px 9px;border:1px solid var(--accent2);background:#fff7f8;border-radius:10px;font-size:12px;font-weight:700;color:#6d222b}
.note-box{background:#fff7f8;border:1px solid var(--accent2);border-radius:18px;padding:14px;color:#5d4047;line-height:1.7;margin:14px 0}
.modal-backdrop{display:none;position:fixed;inset:0;background:rgba(25,0,5,.55);z-index:20;padding:28px;overflow:auto}
.modal{max-width:980px;margin:20px auto;background:#fff;border-radius:24px;box-shadow:0 25px 70px rgba(0,0,0,.35);overflow:hidden}
.modal-head{padding:18px 22px;background:linear-gradient(90deg,#7f1620,#b6202d);color:#fff;display:flex;justify-content:space-between;gap:14px;align-items:center}
.modal-head h2{margin:0;font-size:22px}
.close{background:rgba(255,255,255,.16);color:#fff;border:1px solid rgba(255,255,255,.35);border-radius:12px;padding:8px 10px;cursor:pointer;font-weight:900}
.modal-body{padding:20px}
.detail-grid{display:grid;grid-template-columns:160px 1fr;gap:10px;border:1px solid var(--line);border-radius:18px;overflow:hidden}
.detail-k{background:#fff6f7;color:#6d222b;font-weight:900;padding:11px;border-bottom:1px solid var(--line)}
.detail-v{padding:11px;border-bottom:1px solid var(--line);line-height:1.65}
.footer{padding:16px 22px;background:#fff8f9;border-top:1px solid var(--line);color:var(--muted);font-size:12px;line-height:1.6}
.empty{text-align:center;padding:28px;color:var(--muted);background:#fff;border:1px dashed var(--line);border-radius:18px}
@media(max-width:1000px){.stats-grid{grid-template-columns:repeat(2,1fr)}.grid{grid-template-columns:repeat(2,1fr)}.columns{grid-template-columns:1fr}}
@media(max-width:680px){.hero-top{flex-direction:column}.stats-grid,.grid{grid-template-columns:1fr}.content{padding:14px}.detail-grid{grid-template-columns:1fr}.detail-k{border-bottom:0;padding-bottom:0}.input{min-width:100%}}
</style>
</head>
<body>
<header class="hero">
<div class="hero-top">
<div class="title-block">
<h1>驻印太美国军事群体专题库</h1>
<p>围绕日本、韩国、关岛等地区的美国军事群体、基地网络、地方舆情、SOFA争议、环境健康、NEO撤离与信息安全风险进行结构化展示。</p>
</div>
<div class="version">V1.2</div>
</div>
</header>
<main class="shell">
<section class="panel">
<nav class="tabs" id="tabs"></nav>
<div class="content" id="content"></div>
<div class="footer">资料边界:本页面仅展示公开资料研究数据,避免包含非公开部署细节、敏感坐标、警备路线、具体集合点或可操作化攻击信息。建议在正式发布前继续核验年度人员、预算、地方投诉与民调数据。</div>
</section>
</main>
<div class="modal-backdrop" id="modalBackdrop" onclick="if(event.target.id==='modalBackdrop') closeModal()">
<div class="modal">
<div class="modal-head"><h2 id="modalTitle">详情</h2><button class="close" onclick="closeModal()">关闭</button></div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<script>
const DB = {"metadata": {"title": "驻印太美国军事群体专题数据库", "version": "V1.2", "language": "zh-CN", "generated_at": "2026-06-25", "scope": "公开资料研究型专题,覆盖驻日本、韩国、关岛等印太地区美国军事群体、基地社区、地方舆情、法律制度、环境健康、NEO与信息安全风险。", "safety_note": "本数据库只用于公开资料研究与专题展示,不包含非公开部署细节、敏感坐标、警备路线、具体集合点或可操作化攻击信息。", "primary_modules": ["基地档案", "地方舆情与民调", "历史事件", "人群画像", "SOFA与预算", "环境健康", "NEO与信息安全", "涉华涉台立场"]}, "overview_metrics": [{"label": "印太司令部全区军事人员", "value": "约36万", "note": "skill速查口径,需按DMDC或DoD最新口径周期更新"}, {"label": "驻日美军", "value": "约53,500", "note": "日本全境现役规模概数"}, {"label": "驻韩美军", "value": "约23,600—28,500", "note": "不同统计口径包括实际现役与政策上限口径"}, {"label": "关岛美军", "value": "约7,000+", "note": "不含全部家属、文职和承包商扩展口径"}, {"label": "驻日美军家属", "value": "约35,000", "note": "USFJ公开口径"}, {"label": "日本当地雇员", "value": "约25,000", "note": "驻日基地经济与同盟分担重要变量"}, {"label": "DoDEA太平洋学校", "value": "46所", "note": "服务太平洋美军与合格DoD文职子女"}, {"label": "核心矛盾", "value": "威慑 / 负担 / 法律 / 环境", "note": "专题分析四条主线"}], "tabs": [{"id": "overview", "name": "总览"}, {"id": "bases", "name": "基地档案"}, {"id": "opinion", "name": "地方舆情"}, {"id": "events", "name": "事件时间线"}, {"id": "groups", "name": "人群画像"}, {"id": "legal_budget", "name": "SOFA与预算"}, {"id": "environment", "name": "环境健康"}, {"id": "neo_opsec", "name": "NEO与信息安全"}, {"id": "geopolitics", "name": "涉华涉台"}, {"id": "schemas", "name": "数据字段"}], "bases": [{"id": "JP-YOKOTA-001", "name_cn": "横田空军基地", "name_en": "Yokota Air Base", "country_area": "日本", "region": "日本本土", "location": "东京都多摩地区,东京西北方向", "service": "空军/太空军/联合指挥", "command": "USFJ、第五空军、374th Airlift Wing", "core_position": "驻日美军司令部所在地;印太方向空运、后勤、联络和危机协调枢纽", "units": ["374th Airlift Wing", "第五空军相关机构", "USFJ总部", "多个任务伙伴"], "missions": ["战区空运", "人员与物资输送", "危机支援", "日美联合协调", "首都圈军事联络"], "population": "374th Airlift Wing支撑约11,500人的基地人口;USFJ整体包含约60,000名军人、35,000名家属、7,000名DoD文职/承包商与约25,000名日本雇员。", "community": "首都圈型基地,家属、文职、日方雇员、任务伙伴较多,具有总部—后勤—家庭社区复合属性。", "facilities": ["空运设施", "指挥办公设施", "住房", "学校", "家庭支援", "医疗与生活服务"], "controversies": ["噪音", "空域管制", "首都圈军事存在", "基地开放与周边秩序"], "opinion_tags": ["首都圈基地", "指挥中枢", "空运枢纽", "噪音", "空域"], "visual_hint": "作为驻日美军总部节点展示,并与横须贺、嘉手纳、岩国形成指挥—海军—空军—陆战队网络。", "risk_level": "中", "source_note": "USFJ与Yokota官方公开资料"}, {"id": "JP-YOKOSUKA-002", "name_cn": "横须贺海军基地", "name_en": "Commander Fleet Activities Yokosuka", "country_area": "日本", "region": "日本本土", "location": "神奈川县横须贺市", "service": "海军", "command": "Commander, Navy Region Japan;第七舰队支撑体系", "core_position": "第七舰队前沿部署核心母港之一,支撑航母打击群及前沿部署海军力量。", "units": ["第七舰队相关前沿部署单位", "舰艇维修与后勤单位", "港口保障单位"], "missions": ["舰艇母港", "舰队后勤", "维修", "家庭支援", "港口保障", "同盟联络"], "population": "港口城市型基地,军人、家属、文职、承包商与当地商业区互动频繁。", "community": "基地与横须贺城市经济、旅游、港口身份高度绑定,同时舰艇可见性强。", "facilities": ["港口", "舰艇支援设施", "家庭服务", "宗教服务", "住房", "生活配套", "紧急管理设施"], "controversies": ["核动力舰艇停靠", "港口安全", "夜间秩序", "地方商业依赖", "军人外出行为"], "opinion_tags": ["第七舰队", "航母母港", "港口城市", "地方商业", "核动力舰艇"], "visual_hint": "适合做港口—舰艇—城市经济三层图,不展示具体舰艇行动细节。", "risk_level": "中高", "source_note": "CFA Yokosuka官方公开资料与日媒公开报道"}, {"id": "JP-KADENA-003", "name_cn": "嘉手纳空军基Line truncated
const state = { active:'overview' };
const $ = sel => document.querySelector(sel);
const content = $('#content');
function esc(v){
if(v === null || v === undefined) return '';
return String(v).replace(/[&<>"']/g, s=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[s]));
}
function arr(v){ return Array.isArray(v) ? v : (v ? [v] : []); }
function tags(list){ return arr(list).map(t=>`<span class="tag">${esc(t)}</span>`).join(''); }
function riskClass(r){ if(!r) return 'medium'; if(r.includes('高')) return 'high'; if(r.includes('低')) return 'low'; return 'medium'; }
function includesText(obj, q){ return JSON.stringify(obj).toLowerCase().includes(q.toLowerCase()); }
function filterByRegion(list, region){ return !region || region==='全部' ? list : list.filter(x => (x.region||x.country_area||'').includes(region) || (x.area||'').includes(region)); }
function initTabs(){
$('#tabs').innerHTML = DB.tabs.map(t=>`<button class="tab-btn ${t.id===state.active?'active':''}" onclick="switchTab('${t.id}')">${esc(t.name)}</button>`).join('');
}
function switchTab(id){ state.active=id; initTabs(); render(); window.scrollTo({top:0,behavior:'smooth'}); }
function head(title, desc){ return `<div class="section-head"><div><h2>${esc(title)}</h2><p>${esc(desc)}</p></div></div>`; }
function renderOverview(){
const stats = DB.overview_metrics.map(m=>`<div class="stat"><div class="value">${esc(m.value)}</div><div class="label">${esc(m.label)}</div><div class="note">${esc(m.note)}</div></div>`).join('');
const baseCount = DB.bases.length, opinionCount = DB.local_opinion.length, eventCount = DB.events.length, groupCount = DB.groups.length;
return `${head('专题总览','以“前沿威慑—基地社会—同盟成本—地方冲突—家庭压力—信息安全—台海/朝鲜半岛风险”为主线。')}
<div class="stats-grid">${stats}</div>
<div class="columns">
<div class="list-card"><h3>数据库构成</h3><ul>
<li>基地档案:${baseCount} 条</li><li>地方舆情/民调:${opinionCount} 条</li><li>历史事件:${eventCount} 条</li><li>人群画像:${groupCount} 类</li>
<li>SOFA制度:${DB.legal_sofa.length} 条</li><li>预算条目:${DB.budget.length} 条</li><li>环境健康:${DB.environment.length} 条</li>
</ul></div>
<div class="list-card"><h3>专题主线</h3><ul>
<li>基地网络:日本本土、冲绳、韩国、关岛三层结构。</li>
<li>地方舆情:冲绳反基地、韩国全国/地方分裂、关岛原住民与住房压力。</li>
<li>制度争议:SOFA、刑事管辖、环境进入权、承包商身份。</li>
<li>风险维度:NEO家庭压力、OPSEC、台海和朝鲜半岛危机暴露。</li>
</ul></div>
</div>
<div class="note-box"><b>使用建议:</b>先用“基地档案”建立核心对象,再用“事件时间线”和“地方舆情”解释社会反应,最后用“SOFA与预算”“环境健康”“NEO与信息安全”补足专题深度。</div>`;
}
function baseCard(b){
return `<article class="card"><span class="risk ${riskClass(b.risk_level)}">风险${esc(b.risk_level||'中')}</span><h3>${esc(b.name_cn)}</h3><div class="sub">${esc(b.name_en)} · ${esc(b.country_area)} / ${esc(b.region)}</div>
<div class="meta-row"><b>军种</b><span>${esc(b.service)}</span></div>
<div class="meta-row"><b>上级</b><span>${esc(b.command)}</span></div>
<div class="meta-row"><b>定位</b><span>${esc(b.core_position)}</span></div>
<div class="tags">${tags(b.opinion_tags)}</div><button class="btn" onclick="openBase('${b.id}')">查看详情</button></article>`;
}
function renderBases(){
const regions = ['全部', ...new Set(DB.bases.map(b=>b.region))];
return `${head('基地详细档案','按地区、军种和关键词检索基地,点击卡片可查看完整画像。')}
<div class="toolbar"><input class="input" id="baseSearch" placeholder="搜索:基地名、军种、争议、任务、标签..." oninput="updateBases()"><select class="select" id="baseRegion" onchange="updateBases()">${regions.map(r=>`<option>${esc(r)}</option>`).join('')}</select></div><div class="grid" id="baseGrid"></div>`;
}
function updateBases(){
const q = ($('#baseSearch')?.value || '').trim(); const region = $('#baseRegion')?.value || '全部';
let list = filterByRegion(DB.bases, region); if(q) list = list.filter(b=>includesText(b,q));
$('#baseGrid').innerHTML = list.length ? list.map(baseCard).join('') : '<div class="empty">没有找到匹配基地。</div>';
}
function openBase(id){
const b = DB.bases.find(x=>x.id===id); if(!b) return;
const rows = [
['基地ID', b.id], ['中文名', b.name_cn], ['英文名', b.name_en], ['国家/地区', b.country_area], ['区域', b.region], ['所在地', b.location], ['所属军种', b.service], ['上级体系', b.command], ['核心定位', b.core_position], ['主要单位', arr(b.units).join('、')], ['主要任务', arr(b.missions).join('、')], ['人员/社区', b.population], ['社区特征', b.community], ['主要设施', arr(b.facilities).join('、')], ['争议点', arr(b.controversies).join('、')], ['舆情标签', arr(b.opinion_tags).join('、')], ['可视化建议', b.visual_hint], ['风险等级', b.risk_level], ['资料来源说明', b.source_note]
];
openModal(b.name_cn, detailRows(rows));
}
function renderOpinion(){
const regions = ['全部', ...new Set(DB.local_opinion.map(o=>o.region))];
return `${head('地方舆情与民调数据库','重点呈现冲绳、韩国、关岛三个典型场景:地方负担、全国/地方分裂、原住民与住房压力。')}
<div class="toolbar"><input class="input" id="opSearch" placeholder="搜索:边野古、THAAD、关岛、住房、SOFA..." oninput="updateOpinion()"><select class="select" id="opRegion" onchange="updateOpinion()">${regions.map(r=>`<option>${esc(r)}</option>`).join('')}</select></div>
<div class="table-wrap"><table><thead><tr><th>时间</th><th>地区</th><th>议题</th><th>核心数据</th><th>支持/反对</th><th>深层矛盾</th><th>标签</th></tr></thead><tbody id="opRows"></tbody></table></div>`;
}
function updateOpinion(){
const q = ($('#opSearch')?.value || '').trim(); const region = $('#opRegion')?.value || '全部';
let list = filterByRegion(DB.local_opinion, region); if(q) list = list.filter(o=>includesText(o,q));
$('#opRows').innerHTML = list.map(o=>`<tr><td>${esc(o.date)}</td><td>${esc(o.area)}</td><td><b>${esc(o.issue)}</b><br><span class="sub">${esc(o.source_type)} / 可信度:${esc(o.confidence)}</span></td><td>${esc(o.core_data)}</td><td>支持:${esc(o.support)}<br>反对:${esc(o.oppose)}<br>中立:${esc(o.neutral)}</td><td>${esc(o.deep_conflict)}</td><td>${tags(o.tags)}</td></tr>`).join('') || '<tr><td colspan="7">没有匹配结果。</td></tr>';
}
function renderEvents(){
return `${head('历史事件时间线','将个案事件与SOFA、地方自治、环境健康、基地扩张和大国博弈连接。')}
<div class="toolbar"><input class="input" id="eventSearch" placeholder="搜索:冲绳、SOFA、THAAD、PFAS、关岛..." oninput="updateEvents()"></div>
<div class="timeline" id="eventTimeline"></div>`;
}
function updateEvents(){
const q = ($('#eventSearch')?.value || '').trim(); let list = DB.events; if(q) list = list.filter(e=>includesText(e,q));
$('#eventTimeline').innerHTML = list.map(e=>`<div class="event"><div class="year">${esc(e.date)} · ${esc(e.region)} · ${esc(e.type)}</div><h3>${esc(e.title)}</h3><p>${esc(e.summary)}</p><p><b>影响:</b>${esc(e.impact)}</p><p><b>深层矛盾:</b>${esc(e.deep_conflict)}</p><div class="tags">${tags(e.tags)}</div></div>`).join('') || '<div class="empty">没有匹配事件。</div>';
}
function renderGroups(){
return `${head('人群画像','把“美军群体”拆成军人、家属、文职、承包商、当地雇员和混合国籍家庭。')}
<div class="toolbar"><input class="input" id="groupSearch" placeholder="搜索:配偶、子女、承包商、NEO、就业..." oninput="updateGroups()"></div><div class="grid" id="groupGrid"></div>`;
}
function updateGroups(){
const q = ($('#groupSearch')?.value || '').trim(); let list = DB.groups; if(q) list = list.filter(g=>includesText(g,q));
$('#groupGrid').innerHTML = list.map(g=>`<article class="card"><h3>${esc(g.name)}</h3><div class="sub">${esc(g.category)} · ${esc(g.display)}</div><p>${esc(g.profile)}</p><div class="meta-row"><b>主要需求</b><span>${esc(arr(g.needs).join('、'))}</span></div><div class="meta-row"><b>核心压力</b><span>${esc(arr(g.pressures).join('、'))}</span></div><div class="tags">${tags(g.risks)}</div></article>`).join('') || '<div class="empty">没有匹配画像。</div>';
}
function renderLegalBudget(){
return `${head('SOFA与预算','把法律身份、刑事管辖、环境进入权、同盟分担和基地建设放在同一制度框架下观察。')}
<h3>SOFA制度要点</h3><div class="table-wrap"><table><thead><tr><th>议题</th><th>日本方向</th><th>韩国方向</th><th>专题解释</th></tr></thead><tbody>${DB.legal_sofa.map(x=>`<tr><td><b>${esc(x.topic)}</b></td><td>${esc(x.japan)}</td><td>${esc(x.korea)}</td><td>${esc(x.meaning)}</td></tr>`).join('')}</tbody></table></div>
<h3 style="margin-top:20px">预算与同盟分担</h3><div class="table-wrap"><table><thead><tr><th>地区</th><th>时期</th><th>项目</th><th>金额/口径</th><th>说明</th><th>标签</th></tr></thead><tbody>${DB.budget.map(x=>`<tr><td>${esc(x.area)}</td><td>${esc(x.period)}</td><td><b>${esc(x.item)}</b></td><td>${esc(x.amount)}</td><td>${esc(x.explain)}</td><td>${tags(x.tags)}</td></tr>`).join('')}</tbody></table></div>`;
}
function renderEnvironment(){
return `${head('环境与公共健康','重点覆盖PFAS、噪音、土地占用、海洋生态等与基地社会关系最密切的议题。')}
<div class="grid">${DB.environment.map(e=>`<article class="card"><h3>${esc(e.type)}</h3><div class="sub">涉及地点:${esc(arr(e.locations).join('、'))}</div><p><b>已知问题:</b>${esc(e.known_issue)}</p><p><b>社会影响:</b>${esc(e.social_impact)}</p><div class="meta-row"><b>字段</b><span>${esc(arr(e.fields).join('、'))}</span></div><div class="tags">${tags(e.tags)}</div></article>`).join('')}</div>`;
}
function renderNeoOpsec(){
return `${head('NEO与信息安全','以家庭风险、撤离资格、信息安全治理为主题,不展示具体路线、集合点或可操作细节。')}
<div class="columns"><div><h3>NEO风险场景</h3>${DB.neo.map(n=>`<div class="list-card" style="margin-bottom:12px"><h3>${esc(n.region)}</h3><p><b>触发:</b>${esc(n.risk_trigger)}</p><p><b>对象:</b>${esc(n.objects)}</p><p><b>压力:</b>${esc(n.pressure)}</p><div class="tags">${tags(n.tags)}</div></div>`).join('')}</div>
<div><h3>OPSEC风险类型</h3>${DB.opsec.map(o=>`<div class="list-card" style="margin-bottom:12px"><h3>${esc(o.risk_type)}</h3><p><b>案例:</b>${esc(o.case)}</p><p><b>对象:</b>${esc(o.objects)}</p><p><b>机制:</b>${esc(o.mechanism)}</p><p><b>展示口径:</b>${esc(o.display)}</p><div class="tags">${tags(o.tags)}</div></div>`).join('')}</div></div>`;
}
function renderGeopolitics(){
return `${head('涉华涉台与地缘风险','从第一岛链、台湾问题、朝鲜半岛、关岛角色与中国叙事五个角度呈现。')}
<div class="grid">${DB.geopolitics.map(g=>`<article class="card"><h3>${esc(g.issue)}</h3><p><b>美国官方口径:</b>${esc(g.official_line)}</p><p><b>基地/群体关联:</b>${esc(g.base_link)}</p><p><b>社会争议:</b>${esc(g.social_conflict)}</p><div class="tags">${tags(g.tags)}</div></article>`).join('')}</div>`;
}
function renderSchemas(){
const boxes = Object.entries(DB.schemas).map(([k, fields])=>`<div class="schema-box"><div class="schema-title">${esc(k)}</div><div class="field-list">${fields.map(f=>`<span class="field">${esc(f)}</span>`).join('')}</div></div>`).join('');
const gaps = DB.data_gaps.map(g=>`<tr><td><b>${esc(g.gap)}</b></td><td>${esc(g.priority)}</td><td>${esc(g.explain)}</td></tr>`).join('');
return `${head('数据字段与后续缺口','用于后续扩展JSON、接入外部数据源和长期更新。')}${boxes}<h3>当前数据缺口</h3><div class="table-wrap"><table><thead><tr><th>缺口</th><th>优先级</th><th>说明</th></tr></thead><tbody>${gaps}</tbody></table></div>`;
}
function detailRows(rows){
return `<div class="detail-grid">${rows.map(([k,v])=>`<div class="detail-k">${esc(k)}</div><div class="detail-v">${esc(v)}</div>`).join('')}</div>`;
}
function openModal(title, body){ $('#modalTitle').textContent = title; $('#modalBody').innerHTML = body; $('#modalBackdrop').style.display='block'; document.body.style.overflow='hidden'; }
function closeModal(){ $('#modalBackdrop').style.display='none'; document.body.style.overflow=''; }
function render(){
let html = '';
if(state.active==='overview') html = renderOverview();
if(state.active==='bases') html = renderBases();
if(state.active==='opinion') html = renderOpinion();
if(state.active==='events') html = renderEvents();
if(state.active==='groups') html = renderGroups();
if(state.active==='legal_budget') html = renderLegalBudget();
if(state.active==='environment') html = renderEnvironment();
if(state.active==='neo_opsec') html = renderNeoOpsec();
if(state.active==='geopolitics') html = renderGeopolitics();
if(state.active==='schemas') html = renderSchemas();
content.innerHTML = `<section class="tab-section active">${html}</section>`;
if(state.active==='bases') updateBases();
if(state.active==='opinion') updateOpinion();
if(state.active==='events') updateEvents();
if(state.active==='groups') updateGroups();
}
initTabs(); render();
</script>
</body>
</html>
@@ -0,0 +1,204 @@
<!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 {
--bg1:#7a0d18; --bg2:#3d0710; --card:#fffaf7; --card2:#fff4ec; --ink:#241314; --muted:#6f5452;
--line:#e8cfc9; --accent:#b21f2d; --accent2:#d88a5b; --deep:#520812; --good:#1f7a5b; --warn:#bd6b00; --danger:#a41422;
}
* { box-sizing: border-box; }
body { margin:0; font-family: "Microsoft YaHei", "PingFang SC", system-ui, -apple-system, Segoe UI, sans-serif; color:var(--ink); background: radial-gradient(circle at 20% 0%, #a12b38 0, var(--bg1) 30%, var(--bg2) 100%); }
header { padding: 34px 42px 22px; color:#fff; position:relative; overflow:hidden; }
header:after { content:""; position:absolute; right:-130px; top:-120px; width:360px; height:360px; border:1px solid rgba(255,255,255,.16); border-radius:50%; }
h1 { margin:0; font-size:32px; letter-spacing:.04em; }
.subtitle { margin-top:10px; max-width:1120px; opacity:.88; line-height:1.7; font-size:14px; }
.topbar { display:flex; gap:12px; margin-top:20px; align-items:center; flex-wrap:wrap; }
.search { flex:1; min-width:280px; background:rgba(255,255,255,.14); border:1px solid rgba(255,255,255,.38); color:#fff; border-radius:999px; padding:12px 18px; outline:none; }
.search::placeholder { color:rgba(255,255,255,.72); }
.btn { border:none; background:#fff; color:var(--deep); border-radius:999px; padding:10px 16px; cursor:pointer; font-weight:700; box-shadow:0 10px 24px rgba(0,0,0,.12); }
.container { padding:0 28px 42px; }
.shell { background:rgba(255,255,255,.92); border:1px solid rgba(255,255,255,.55); border-radius:28px; min-height:680px; box-shadow:0 28px 70px rgba(0,0,0,.25); overflow:hidden; }
.tabs { display:flex; gap:8px; overflow:auto; padding:14px 18px; background:#fff8f4; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:5; }
.tab { white-space:nowrap; padding:10px 15px; border:1px solid var(--line); background:white; color:var(--muted); border-radius:999px; cursor:pointer; font-weight:700; }
.tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
.panel { display:none; padding:24px; }
.panel.active { display:block; }
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(280px,1fr)); gap:16px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:20px; padding:18px; box-shadow:0 10px 20px rgba(80,20,20,.06); cursor:pointer; transition:.18s; }
.card:hover { transform:translateY(-2px); box-shadow:0 16px 30px rgba(80,20,20,.12); }
.card h3 { margin:0 0 8px; font-size:18px; color:var(--deep); }
.meta { color:var(--muted); font-size:13px; line-height:1.6; }
.tags { display:flex; flex-wrap:wrap; gap:6px; margin:10px 0; }
.tag { border:1px solid #e0b8ad; color:#7a1a24; background:#fff; border-radius:999px; padding:4px 9px; font-size:12px; }
.kpis { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px,1fr)); gap:14px; margin-bottom:18px; }
.kpi { background:linear-gradient(135deg,#fff,#fff0e7); border:1px solid var(--line); border-radius:18px; padding:18px; }
.kpi .num { font-size:32px; font-weight:800; color:var(--accent); }
.kpi .label { color:var(--muted); margin-top:4px; }
.section-title { display:flex; align-items:center; justify-content:space-between; gap:14px; margin:0 0 14px; }
.section-title h2 { margin:0; font-size:22px; color:var(--deep); }
.filters { display:flex; gap:8px; flex-wrap:wrap; margin:12px 0 18px; }
.filter { padding:8px 12px; border-radius:999px; border:1px solid var(--line); background:#fff; cursor:pointer; color:var(--muted); }
.filter.active { background:var(--deep); color:#fff; }
.table-wrap { overflow:auto; border:1px solid var(--line); border-radius:18px; background:#fff; }
table { border-collapse:collapse; width:100%; min-width:900px; }
th,td { padding:12px 13px; border-bottom:1px solid #f1ded9; text-align:left; vertical-align:top; font-size:13px; line-height:1.55; }
th { background:#fff0e9; color:#5a1520; position:sticky; top:0; z-index:1; }
tr:hover td { background:#fffaf7; }
.chain { display:grid; gap:10px; }
.step { display:flex; gap:12px; align-items:flex-start; background:#fff; border:1px solid var(--line); border-radius:16px; padding:12px; }
.step .dot { width:26px; height:26px; border-radius:50%; background:var(--accent); color:#fff; display:grid; place-items:center; flex:0 0 auto; font-size:12px; font-weight:800; }
.risk { display:grid; grid-template-columns:repeat(auto-fit, minmax(230px,1fr)); gap:12px; }
.riskbox { border:1px solid var(--line); border-radius:16px; padding:14px; background:#fff; }
.riskbox strong { color:var(--deep); }
.badge { padding:3px 8px; border-radius:999px; font-size:12px; background:#fff0e9; color:#7a1a24; }
.graphbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:12px; }
.graph { background:#fff; border:1px solid var(--line); border-radius:20px; height:660px; overflow:hidden; position:relative; }
svg { width:100%; height:100%; }
.node-label { font-size:12px; fill:#3d0710; font-weight:700; pointer-events:none; }
.edge { stroke:#a4786d; stroke-opacity:.35; }
.edge.strong { stroke:#a41422; stroke-opacity:.75; }
.node { cursor:pointer; }
.legend { display:flex; gap:8px; flex-wrap:wrap; margin-top:10px; color:var(--muted); font-size:13px; }
.legend span { background:#fff; border:1px solid var(--line); border-radius:999px; padding:5px 9px; }
.modal { position:fixed; inset:0; background:rgba(0,0,0,.44); display:none; align-items:center; justify-content:center; z-index:20; padding:20px; }
.modal.show { display:flex; }
.modal-card { width:min(860px, 94vw); max-height:86vh; overflow:auto; background:#fff; border-radius:24px; padding:24px; box-shadow:0 30px 100px rgba(0,0,0,.4); }
.modal-head { display:flex; justify-content:space-between; gap:14px; align-items:flex-start; border-bottom:1px solid var(--line); padding-bottom:12px; margin-bottom:14px; }
.close { border:none; border-radius:50%; width:36px; height:36px; background:#fff0e9; color:var(--deep); cursor:pointer; font-size:20px; }
.detail-grid { display:grid; grid-template-columns:180px 1fr; gap:8px 14px; font-size:14px; line-height:1.7; }
.detail-grid b { color:#6f242c; }
.small { font-size:12px; color:var(--muted); }
.source-link { color:var(--accent); text-decoration:none; }
.empty { padding:24px; color:var(--muted); text-align:center; }
@media (max-width:760px) { header { padding:24px 18px; } .container { padding:0 12px 28px; } .panel { padding:16px; } .detail-grid { grid-template-columns:1fr; } }
</style>
</head>
<body>
<header>
<h1>美国涉华涉台政策权力网络专题平台</h1>
<div class="subtitle">基于公开资料与 Skill 框架整理的结构化研究平台:覆盖白宫、行政部门、国会委员会、利益集团、智库媒体、摩擦事件、风险指标与关系图谱。研究边界:公开资料、结构性分析、风险预警;不包含私人信息或定向影响操作。</div>
<div class="topbar"><input id="globalSearch" class="search" placeholder="搜索节点、人物、委员会、政策工具、事件、风险指标……"><button class="btn" onclick="downloadJSON()">下载当前 JSON</button><button class="btn" onclick="resetSearch()">重置搜索</button></div>
</header>
<div class="container">
<div class="shell">
<nav class="tabs" id="tabs"></nav>
<section id="overview" class="panel active"></section>
<section id="nodes" class="panel"></section>
<section id="tools" class="panel"></section>
<section id="congress" class="panel"></section>
<section id="graph" class="panel"></section>
<section id="chains" class="panel"></section>
<section id="interest" class="panel"></section>
<section id="thinktank" class="panel"></section>
<section id="events" class="panel"></section>
<section id="risks" class="panel"></section>
<section id="sources" class="panel"></section>
</div>
</div>
<div id="modal" class="modal"><div class="modal-card"><div class="modal-head"><div><h2 id="modalTitle" style="margin:0;color:var(--deep)"></h2><div id="modalSub" class="meta"></div></div><button class="close" onclick="closeModal()">×</button></div><div id="modalBody"></div></div></div>
<script>
const DATA = {"metadata": {"标题": "美国涉华涉台政策权力网络专题数据库", "英文标题": "US-China Policy Power Network Dataset", "版本": "v1.0", "生成日期": "2026-06-25", "时间口径": "截至2026年6月25日的公开资料与本专题已补充数据。", "研究边界": "仅使用公开资料进行结构性政策研究、风险预警和认知防护分析;不包含私人信息、定向影响操作或作战敏感细节。", "证据等级说明": {"A": "官方网页、国会官网、政府文件直接证明", "B": "权威媒体、主流智库、权威数据库支持", "C": "基于公开结构的合理推断,需后续核验"}}, "dashboard": {"栏目": ["总览仪表盘", "白宫与NSC", "行政工具系统", "国会网络", "利益集团", "智库媒体", "摩擦事件", "影响链条", "风险预警", "关系图谱", "来源审查"], "统计": {"节点数量": 79, "边数量": 101, "政策工具数量": 10, "事件数量": 6, "风险指标数量": 8, "影响链条数量": 5}, "五层结构": ["第一层:总统 / 白宫 / NSC", "第二层:行政部门工具系统", "第三层:国会监督与立法系统", "第四层:利益攸关群体", "第五层:舆论与认知场"], "核心判断": "白宫定调,NSC协调,行政部门掌握工具,国会负责立法和施压,利益集团提供资源、数据和游说,智库媒体塑造议题,民意决定政策成本。"}, "nodes": [{"id": "P001", "名称": "Donald J. Trump / 白宫", "英文名称": "Donald J. Trump / The White House", "节点类型": "人物/机构", "所属层级": "第一层:总统 / 白宫 / NSC", "政策领域": ["总体战略", "关税", "台湾", "峰会外交"], "权力来源": "总统正式决策权;行政令、峰会、关税与危机反应定调。", "政策工具": ["行政令", "峰会声明", "关税安排", "军售节奏"], "核心诉求": ["政策交易空间", "国内政治成本控制", "美国优先议程"], "涉华立场": "竞争性、交易型、工具化强。", "涉台立场": "维持美台关系工具性与危机控制并行。", "近期动作": ["白宫发布内阁与政策事实清单。"], "风险信号": ["总统直接点名台湾或关税;中美峰会取消;军售节奏异常。"], "证据等级": "A", "来源": ["S001"]}, {"id": "A001", "名称": "Marco Rubio", "英文名称": "Marco Rubio", "节点类型": "人物", "所属层级": "第二层:行政部门工具系统", "政策领域": ["外交", "台湾", "人权", "盟友协调"], "权力来源": "国务卿,掌握外交声明、国务院政策口径与盟友协调。", "政策工具": ["外交声明", "签证限制", "盟友联合声明", "台湾交往指南"], "核心诉求": ["强化美国外交优势", "价值观议题与安全议题联动"], "涉华立场": "强硬竞争与价值观议题结合。", "涉台立场": "支持台湾国际空间与非官方关系强化。", "近期动作": ["白宫列名其为国务卿。"], "风险信号": ["国务院升级台湾表述;联合盟友点名中国行动。"], "证据等级": "A", "来源": ["S001"]}, {"id": "A002", "名称": "Pete Hegseth", "英文名称": "Pete Hegseth", "节点类型": "人物", "所属层级": "第二层:行政部门工具系统", "政策领域": ["军事安全", "台海", "印太", "军售"], "权力来源": "白宫页面列为Secretary of War;负责军事工具、国防预算与威慑态势。", "政策工具": ["军售", "印太部署", "国防预算", "军事演训"], "核心诉求": ["提升战备", "威慑对手", "强化军工与前沿部署"], "涉华立场": "以军事威慑和战备为主。", "涉台立场": "支持强化台海威慑与区域协同。", "近期动作": ["白宫页面列其为Secretary of War。"], "风险信号": ["印太军力前推;NDAA台湾条款加码;军售加速。"], "证据等级": "A", "来源": ["S001"]}, {"id": "A003", "名称": "Scott Bessent", "英文名称": "Scott Bessent", "节点类型": "人物", "所属层级": "第二层:行政部门工具系统", "政策领域": ["金融", "制裁", "投资审查"], "权力来源": "财政部长,影响OFAC、CFIUS、对外投资限制和金融风险边界。", "政策工具": ["OFAC制裁", "CFIUS", "对外投资安全规则", "金融沟通"], "核心诉求": ["金融稳定", "资本安全", "敏感技术投资边界"], "涉华立场": "通过金融和资本工具塑造竞争边界。", "涉台立场": "可通过制裁或投资限制影响涉台军工与技术网络。", "近期动作": ["白宫列名其为财政部长。"], "风险信号": ["制裁大型机构;CFIUS扩张;对外投资规则加码。"], "证据等级": "A", "来源": ["S001", "S007"]}, {"id": "A004", "名称": "Howard Lutnick", "英文名称": "Howard Lutnick", "节点类型": "人物", "所属层级": "第二层:行政部门工具系统", "政策领域": ["出口管制", "半导体", "产业安全"], "权力来源": "商务部长,影响BIS、实体清单、许可证和产Line truncated
const tabs = [
["overview","总览仪表盘"],["nodes","权力节点"],["tools","行政工具"],["congress","国会网络"],["graph","关系图谱"],["chains","影响链条"],["interest","利益集团"],["thinktank","智库媒体"],["events","摩擦事件"],["risks","风险预警"],["sources","来源审查"]
];
let query = "";
let graphFilter = "全部";
const $ = id => document.getElementById(id);
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m])); }
function arr(v) { return Array.isArray(v) ? v : (v ? [v] : []); }
function tags(list) { return `<div class="tags">${arr(list).map(x=>`<span class="tag">${esc(x)}</span>`).join('')}</div>`; }
function match(obj) { if(!query) return true; return JSON.stringify(obj).toLowerCase().includes(query.toLowerCase()); }
function short(text, n=140) { text = String(text||''); return text.length>n ? text.slice(0,n)+'……' : text; }
function initTabs() {
$('tabs').innerHTML = tabs.map((t,i)=>`<button class="tab ${i==0?'active':''}" onclick="showTab('${t[0]}', this)">${t[1]}</button>`).join('');
}
function showTab(id, btn) { document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active')); $(id).classList.add('active'); document.querySelectorAll('.tab').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); if(id==='graph') renderGraph(); }
function card(obj, title, sub, body, click=true) { return `<div class="card" ${click?`onclick='openDetail(${JSON.stringify(obj).replace(/'/g,"&apos;")})'`:''}><h3>${esc(title)}</h3><div class="meta">${esc(sub||'')}</div>${body||''}</div>`; }
function openDetail(obj) {
$('modalTitle').textContent = obj.名称 || obj.工具名称 || obj.事件名称 || obj.指标名称 || obj.名称 || obj.edge_id || obj.id || '详情';
$('modalSub').textContent = obj.英文名称 || obj.节点类型 || obj.类型 || obj.政策性质 || obj.时间 || '';
let rows = Object.entries(obj).filter(([k])=>!['可视化样式'].includes(k)).map(([k,v])=>`<b>${esc(k)}</b><div>${Array.isArray(v)? v.map(esc).join('、') : typeof v === 'object' && v ? `<pre>${esc(JSON.stringify(v,null,2))}</pre>` : esc(v)}</div>`).join('');
$('modalBody').innerHTML = `<div class="detail-grid">${rows}</div>`;
$('modal').classList.add('show');
}
function closeModal() { $('modal').classList.remove('show'); }
function resetSearch() { query=''; $('globalSearch').value=''; renderAll(); }
$('globalSearch').addEventListener('input', e=>{ query=e.target.value.trim(); renderAll(); });
function renderOverview() {
const s=DATA.dashboard.统计;
$('overview').innerHTML = `<div class="section-title"><h2>总览仪表盘</h2><span class="badge">${esc(DATA.metadata.版本)} · ${esc(DATA.metadata.生成日期)}</span></div>
<div class="kpis">${Object.entries(s).map(([k,v])=>`<div class="kpi"><div class="num">${v}</div><div class="label">${k}</div></div>`).join('')}</div>
<div class="grid">
${card(DATA.dashboard, '五层权力结构', DATA.dashboard.核心判断, tags(DATA.dashboard.五层结构), false)}
${card(DATA.metadata, '研究边界', DATA.metadata.时间口径, `<p class="meta">${esc(DATA.metadata.研究边界)}</p>`, false)}
${card(DATA.metadata.证据等级说明, '证据等级', 'A/B/C 分级用于页面筛选和来源审查', `<div class="detail-grid"><b>A</b><div>${DATA.metadata.证据等级说明.A}</div><b>B</b><div>${DATA.metadata.证据等级说明.B}</div><b>C</b><div>${DATA.metadata.证据等级说明.C}</div></div>`, false)}
</div>`;
}
const layers = ["全部","第一层:总统 / 白宫 / NSC","第二层:行政部门工具系统","第三层:国会监督与立法系统","第四层:利益攸关群体","第五层:舆论与认知场"];
let nodeLayer='全部';
function renderNodes() {
const filtered = DATA.nodes.filter(x=>match(x) && (nodeLayer==='全部'||x.所属层级===nodeLayer));
$('nodes').innerHTML = `<div class="section-title"><h2>权力节点库</h2><span class="badge">${filtered.length} / ${DATA.nodes.length}</span></div>
<div class="filters">${layers.map(l=>`<button class="filter ${nodeLayer===l?'active':''}" onclick="nodeLayer='${l}';renderNodes()">${l.replace(':',':<br>')}</button>`).join('')}</div>
<div class="grid">${filtered.map(x=>card(x,x.名称,x.所属层级,`${tags(x.政策领域)}<p class="meta">${esc(short(x.权力来源 || x.核心诉求?.[0]))}</p>`)).join('') || '<div class="empty">没有匹配结果</div>'}</div>`;
}
function renderTools() {
const rows = DATA.policy_tools.filter(match).map(x=>`<tr onclick='openDetail(${JSON.stringify(x).replace(/'/g,"&apos;")})'><td>${esc(x.工具名称)}</td><td>${esc(x.主责机构)}</td><td>${esc(x.政策性质)}</td><td>${arr(x.政策领域).map(esc).join('、')}</td><td>${esc(x.功能说明)}</td><td>${esc(x.建议字段)}</td></tr>`).join('');
$('tools').innerHTML = `<div class="section-title"><h2>行政政策工具系统</h2><span class="badge">${DATA.policy_tools.filter(match).length} 项</span></div><div class="table-wrap"><table><thead><tr><th>工具名称</th><th>主责机构</th><th>政策性质</th><th>政策领域</th><th>功能说明</th><th>建议字段</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}
function renderCongress() {
const congress = DATA.nodes.filter(x=>match(x) && (x.所属层级||'').includes('国会'));
const legs = DATA.legislation.filter(match);
$('congress').innerHTML = `<div class="section-title"><h2>国会委员会、人物与法案库</h2><span class="badge">${congress.length} 个节点 · ${legs.length} 项法案/议题</span></div>
<div class="grid">${congress.map(x=>card(x,x.名称,x.权力来源,tags(x.政策领域))).join('')}</div>
<h2 style="margin-top:24px;color:var(--deep)">法案与立法议题</h2><div class="table-wrap"><table><thead><tr><th>名称</th><th>状态</th><th>牵引节点</th><th>意义</th><th>证据</th></tr></thead><tbody>${legs.map(x=>`<tr onclick='openDetail(${JSON.stringify(x).replace(/'/g,"&apos;")})'><td>${esc(x.名称)}</td><td>${esc(x.状态)}</td><td>${arr(x.牵引节点).map(esc).join('、')}</td><td>${esc(x.涉华涉台意义)}</td><td>${esc(x.证据等级)}</td></tr>`).join('')}</tbody></table></div>`;
}
function renderInterest() {
const list = DATA.nodes.filter(x=>match(x) && x.所属层级==='第四层:利益攸关群体');
$('interest').innerHTML = `<div class="section-title"><h2>利益集团画像</h2><span class="badge">${list.length} 个节点</span></div><div class="grid">${list.map(x=>card(x,x.名称,x.节点类型,`${tags(x.政策领域)}<p class="meta">${esc(short(x.核心诉求?.[0],180))}</p>`)).join('')}</div>`;
}
function renderThinktank() {
const list = DATA.nodes.filter(x=>match(x) && x.所属层级==='第五层:舆论与认知场');
$('thinktank').innerHTML = `<div class="section-title"><h2>智库媒体与叙事生态</h2><span class="badge">${list.length} 个节点</span></div><div class="grid">${list.map(x=>card(x,x.名称,x.英文名称,`${tags(x.政策领域)}<p class="meta">${esc(short(x.核心诉求?.[0],180))}</p>`)).join('')}</div>`;
}
function renderEvents() {
const list=DATA.friction_events.filter(match);
$('events').innerHTML=`<div class="section-title"><h2>摩擦事件库</h2><span class="badge">${list.length} 件</span></div><div class="grid">${list.map(x=>card(x,x.事件名称,`${x.时间} · ${x.类型} · 风险:${x.风险等级}`,`${tags(x.涉及主体)}<p class="meta"><b>美国动作:</b>${esc(short(x.美国动作))}</p><p class="meta"><b>中方可能反应:</b>${esc(short(x.中方可能反应))}</p>`)).join('')}</div>`;
}
function renderRisks() {
const list=DATA.risk_indicators.filter(match);
$('risks').innerHTML=`<div class="section-title"><h2>风险预警指标体系</h2><span class="badge">${list.length} 个指标</span></div><div class="grid">${list.map(x=>`<div class="card" onclick='openDetail(${JSON.stringify(x).replace(/'/g,"&apos;")})'><h3>${esc(x.指标名称)}</h3>${tags(x.关联领域)}<div class="risk"><div class="riskbox"><strong>低</strong><br>${esc(x.低风险)}</div><div class="riskbox"><strong>中</strong><br>${esc(x.中风险)}</div><div class="riskbox"><strong>高</strong><br>${esc(x.高风险)}</div><div class="riskbox"><strong>极高</strong><br>${esc(x.极高风险)}</div></div></div>`).join('')}</div>`;
}
function renderChains() {
const list=DATA.influence_chains.filter(match);
$('chains').innerHTML=`<div class="section-title"><h2>关键影响链条</h2><span class="badge">${list.length} 条</span></div><div class="grid">${list.map(c=>`<div class="card" onclick='openDetail(${JSON.stringify(c).replace(/'/g,"&apos;")})'><h3>${esc(c.名称)}</h3><span class="badge">风险:${esc(c.风险)}</span><div class="chain" style="margin-top:12px">${c.路径.map((s,i)=>`<div class="step"><div class="dot">${i+1}</div><div>${esc(s)}</div></div>`).join('')}</div></div>`).join('')}</div>`;
}
function renderSources() {
const list=DATA.sources.filter(match);
$('sources').innerHTML=`<div class="section-title"><h2>来源审查</h2><span class="badge">${list.length} 个来源</span></div><div class="table-wrap"><table><thead><tr><th>ID</th><th>名称</th><th>类型</th><th>等级</th><th>说明</th><th>链接</th></tr></thead><tbody>${list.map(s=>`<tr><td>${s.id}</td><td>${esc(s.名称)}</td><td>${esc(s.类型)}</td><td>${esc(s.证据等级)}</td><td>${esc(s.说明)}</td><td><a class="source-link" href="${esc(s.url)}" target="_blank">打开来源</a></td></tr>`).join('')}</tbody></table></div>`;
}
function renderGraph() {
const fields=["全部",...Array.from(new Set(DATA.edges.flatMap(e=>e.政策领域||[]))).slice(0,18)];
const edges=DATA.edges.filter(e=>match(e) && (graphFilter==='全部'||(e.政策领域||[]).includes(graphFilter))).slice(0,120);
const ids=new Set(edges.flatMap(e=>[e.source,e.target]));
const nodes=DATA.nodes.filter(n=>ids.has(n.id) || ids.has(n.名称)).slice(0,80);
const fallbackIds=[...ids].filter(id=>!nodes.some(n=>n.id===id||n.名称===id)).map(id=>({id,名称:id,所属层级:'外部概念',节点类型:'概念',政策领域:[]}));
const all=[...nodes,...fallbackIds];
const layersOrder=["第一层:总统 / 白宫 / NSC","第二层:行政部门工具系统","第三层:国会监督与立法系统","第四层:利益攸关群体","第五层:舆论与认知场","外部概念"];
const width=1200, height=640, margin=70;
const pos={};
layersOrder.forEach((ly,li)=>{ const group=all.filter(n=>(n.所属层级||'外部概念')===ly); group.forEach((node,i)=>{ pos[node.id]=pos[node.名称]={x: margin + li*(width-2*margin)/(layersOrder.length-1), y: 55 + (i+1)*(height-110)/(group.length+1)}; }); });
const color=(ly)=>({"第一层:总统 / 白宫 / NSC":"#a41422","第二层:行政部门工具系统":"#d45a35","第三层:国会监督与立法系统":"#7a1f77","第四层:利益攸关群体":"#276f55","第五层:舆论与认知场":"#2f68a8","外部概念":"#999"}[ly]||'#777');
const lineSvg=edges.map(ed=>{ const a=pos[ed.source], b=pos[ed.target]; if(!a||!b) return ''; return `<line class="edge ${ed.强度>=5?'strong':''}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke-width="${Math.max(1,ed.强度/1.3)}"><title>${esc(ed.关系类型)}</title></line>`; }).join('');
const nodeSvg=all.map(nd=>{ const p=pos[nd.id]||pos[nd.名称]; if(!p) return ''; const r = nd.节点类型==='人物'?12: nd.节点类型==='委员会'?15: 13; return `<g class="node" onclick='openDetail(${JSON.stringify(nd).replace(/'/g,"&apos;")})'><circle cx="${p.x}" cy="${p.y}" r="${r}" fill="${color(nd.所属层级||'外部概念')}" stroke="#fff" stroke-width="2"><title>${esc(nd.名称)}</title></circle><text class="node-label" x="${p.x+16}" y="${p.y+4}">${esc(nd.名称).slice(0,18)}</text></g>`; }).join('');
$('graph').innerHTML=`<div class="section-title"><h2>关系图谱</h2><span class="badge">${edges.length} 条边 · ${all.length} 个节点</span></div><div class="graphbar">${fields.map(f=>`<button class="filter ${graphFilter===f?'active':''}" onclick="graphFilter='${f}';renderGraph()">${esc(f)}</button>`).join('')}</div><div class="graph"><svg viewBox="0 0 ${width} ${height}">${lineSvg}${nodeSvg}</svg></div><div class="legend"><span>红:白宫</span><span>橙:行政部门/工具</span><span>紫:国会</span><span>绿:利益集团</span><span>蓝:智库媒体</span><span>线越粗关系越强</span></div>`;
}
function renderAll() { renderOverview(); renderNodes(); renderTools(); renderCongress(); renderGraph(); renderChains(); renderInterest(); renderThinktank(); renderEvents(); renderRisks(); renderSources(); }
function downloadJSON() { const blob=new Blob([JSON.stringify(DATA,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='us_china_policy_power_network_data.json'; a.click(); URL.revokeObjectURL(a.href); }
initTabs(); renderAll();
</script>
</body>
</html>
@@ -0,0 +1,273 @@
<!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:#781d22;--bg2:#4d1014;--card:#fffaf5;--card2:#fff3eb;--soft:#f8e8de;--line:rgba(95,27,31,.22);
--text:#261313;--muted:#755e5e;--accent:#b33a3f;--accent2:#8e2f34;--gold:#b98b54;--ok:#236e56;--warn:#a05a16;
--shadow:0 18px 55px rgba(45,10,12,.22);--radius:22px;--radius2:14px;
}
*{box-sizing:border-box} body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",Arial,sans-serif;background:radial-gradient(circle at 20% 0%,#9b3035 0,#781d22 36%,#4d1014 100%);color:var(--text)}
a{color:var(--accent2);text-decoration:none} a:hover{text-decoration:underline}.wrap{max-width:1500px;margin:0 auto;padding:28px 22px 48px}.hero{background:linear-gradient(135deg,rgba(255,250,245,.96),rgba(247,232,222,.92));border:1px solid rgba(255,255,255,.52);border-radius:30px;box-shadow:var(--shadow);padding:28px;position:relative;overflow:hidden}.hero:after{content:"";position:absolute;right:-80px;top:-80px;width:300px;height:300px;border-radius:50%;background:rgba(179,58,63,.12)}.eyebrow{font-size:13px;color:var(--accent2);font-weight:800;letter-spacing:.12em;text-transform:uppercase}.hero h1{margin:10px 0 8px;font-size:34px;line-height:1.2;color:#2b1113}.subtitle{color:var(--muted);font-size:16px;max-width:940px;line-height:1.75}.pillbar{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}.pill,.tag{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:#fff;border:1px solid var(--line);font-size:12px;color:#553333}.pill.strong{background:#792026;color:#fff;border-color:#792026}.toolbar{margin:18px 0;background:rgba(255,250,245,.94);border:1px solid rgba(255,255,255,.45);border-radius:22px;box-shadow:0 10px 32px rgba(39,7,9,.14);padding:14px;display:grid;grid-template-columns:minmax(260px,1.6fr) repeat(4,minmax(130px,.7fr)) auto;gap:10px;align-items:center}.toolbar input,.toolbar select,.toolbar button{height:42px;border-radius:14px;border:1px solid var(--line);background:#fff;color:var(--text);padding:0 12px;font-size:14px}.toolbar button{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:700;cursor:pointer}.tabs{display:flex;gap:8px;overflow-x:auto;padding:4px 0 12px}.tab{border:1px solid rgba(255,255,255,.45);background:rgba(255,250,245,.88);color:#5b2a2d;border-radius:999px;padding:10px 16px;white-space:nowrap;font-weight:800;cursor:pointer}.tab.active{background:#fff;color:#781d22;box-shadow:0 8px 22px rgba(55,8,10,.2)}.panel{background:rgba(255,250,245,.96);border:1px solid rgba(255,255,255,.52);border-radius:28px;box-shadow:var(--shadow);padding:22px;min-height:560px}.panel-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-end;margin-bottom:18px}.panel-title{font-size:25px;font-weight:900;margin:0;color:#2d1214}.panel-desc{color:var(--muted);line-height:1.65;margin:8px 0 0}.count-badge{background:#5a1519;color:#fff;border-radius:999px;padding:7px 12px;font-size:13px;font-weight:800}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.grid.two{grid-template-columns:repeat(2,minmax(0,1fr))}.card{background:#fff;border:1px solid var(--line);border-radius:20px;padding:16px;box-shadow:0 7px 18px rgba(51,16,15,.07);cursor:pointer;transition:.15s}.card:hover{transform:translateY(-2px);box-shadow:0 14px 32px rgba(51,16,15,.14);border-color:rgba(179,58,63,.45)}.card h3{margin:0 0 8px;font-size:18px;color:#2a1012;line-height:1.35}.card .meta{color:var(--muted);font-size:13px;line-height:1.6;margin-bottom:10px}.summary{font-size:14px;line-height:1.72;color:#3b2222;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.kv{display:grid;grid-template-columns:150px 1fr;gap:8px;border-top:1px dashed var(--line);padding-top:10px;margin-top:10px;font-size:14px}.kv b{color:#5b2225}.list{display:flex;flex-wrap:wrap;gap:7px;margin-top:8px}.chip{display:inline-flex;padding:5px 9px;border-radius:999px;background:var(--soft);border:1px solid var(--line);font-size:12px;color:#653234}.chip.red{background:#ffe3de;color:#8b2327}.chip.green{background:#e9f5ef;color:#236e56}.chip.gold{background:#fff0d9;color:#8d5816}.stat-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:18px}.stat{background:#fff;border:1px solid var(--line);border-radius:20px;padding:16px}.stat .num{font-size:28px;font-weight:950;color:#8c2429}.stat .lab{color:var(--muted);font-size:13px;margin-top:4px}.section-grid{display:grid;grid-template-columns:1.2fr .8fr;gap:16px}.info-box{background:#fff;border:1px solid var(--line);border-radius:20px;padding:18px;margin-bottom:14px}.info-box h3{margin:0 0 12px;color:#3a1517}.info-box p,.info-box li{line-height:1.75;color:#442626}.timeline{position:relative;padding-left:22px}.timeline:before{content:"";position:absolute;left:8px;top:4px;bottom:4px;width:2px;background:#e2c6bd}.titem{position:relative;background:#fff;border:1px solid var(--line);border-radius:18px;padding:14px 16px;margin-bottom:12px;cursor:pointer}.titem:before{content:"";position:absolute;left:-20px;top:20px;width:12px;height:12px;border-radius:50%;background:var(--accent);border:3px solid #fff;box-shadow:0 0 0 1px var(--accent)}.tdate{font-weight:900;color:#8c2429;font-size:13px}.matrix{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.matrix-card{background:#fff;border:1px solid var(--line);border-radius:20px;padding:16px;cursor:pointer}.split{display:grid;grid-template-columns:1fr 1fr;gap:16px}.table-wrap{overflow:auto;background:#fff;border:1px solid var(Line truncated
@media(max-width:1100px){.toolbar{grid-template-columns:1fr 1fr}.grid,.grid.two,.matrix,.split,.section-grid,.stat-grid{grid-template-columns:1fr 1fr}.doc-layout{grid-template-columns:1fr}.doc-nav{position:relative;max-height:none}.field{grid-template-columns:1fr}.subcards{grid-template-columns:1fr}}
@media(max-width:720px){.wrap{padding:14px 10px}.hero h1{font-size:25px}.toolbar,.grid,.grid.two,.matrix,.split,.section-grid,.stat-grid{grid-template-columns:1fr}.modal{padding:8px}.modal-head{padding:16px}.modal-body{padding:16px}.panel{padding:14px}.tabs{gap:6px}.tab{padding:9px 12px;font-size:13px}}
</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<div class="eyebrow">Research Dashboard · Enhanced Display</div>
<h1 id="pageTitle">美国左翼/进步群体涉华涉台专题研究平台(增强版)</h1>
<div class="subtitle" id="pageSubtitle"></div>
<div class="pillbar" id="boundaryPills"></div>
</header>
<section class="toolbar">
<input id="q" placeholder="搜索组织、事件、叙事、关系、来源……" />
<select id="factionFilter"><option value="">全部阵营</option></select>
<select id="relevanceFilter"><option value="">全部相关度</option></select>
<select id="confidenceFilter"><option value="">全部可信度</option></select>
<select id="yearFilter"><option value="">全部年份</option></select>
<button id="resetBtn">重置</button>
</section>
<nav class="tabs" id="tabs"></nav>
<main class="panel" id="panel"></main>
<div class="footer-note">本页面仅用于公开资料研究与来源核验,不包含普通成员隐私、个人弱点、线下行动操作指南或定向操纵建议。</div>
</div>
<div class="modal" id="modal" onclick="if(event.target.id==='modal') closeModal()">
<div class="modal-card">
<div class="modal-head">
<div><div class="modal-title" id="modalTitle"></div><div class="modal-sub" id="modalSub"></div></div>
<button class="close" onclick="closeModal()">×</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<script id="data-json" type="application/json">{"meta": {"title": "美国左翼/进步群体涉华涉台专题研究平台(增强版)", "subtitle": "公开资料研究 · 组织深描 · 事件时间线 · 叙事语料 · 关系图谱 · 争议与来源", "version": "v2-enhanced-display", "updated_at": "2026-06-25T18:20:00+09:00", "language": "zh-CN", "scope_note": "本数据包基于公开网页、组织自述、媒体报道和政策/倡议文本,服务学术研究和公开资料分析。组织立场不等于普通成员个人立场;间接相关组织不得被强行解释为涉台组织。", "safety_boundary": {"allowed_scope": ["公开资料研究", "组织层级生态分析", "非定向叙事风险评估", "来源可信度审查", "公共沟通误读预警"], "excluded": ["个人脆弱点", "住址/行程/家庭/普通成员账号", "线下行动操作指南", "规避执法或平台审核", "欺骗性影响行动", "冒充身份或假草根动员"], "terminology_rule": "将“脆弱点”统一改写为“内部矛盾、传播风险、误读点、韧性需求”。"}, "data_note": "展示层 JSON 由 v2 原始数据转换而成,供单文件 HTML 离线检索、筛选、弹框展示使用。", "enhancement_note": "增强版将弹框从原始 JSON 改为分区式详情页,并增加 Markdown 原始研究摘编、组织深描、事件研究价值、争议处理和来源可信度说明。"}, "tabs": [{"id": "overview", "label": "专题总览", "dataset": "dashboard", "layout": "dashboard"}, {"id": "factions", "label": "阵营分类", "dataset": "factions", "layout": "cards", "modal": "factions"}, {"id": "organizations", "label": "组织画像", "dataset": "organizations", "layout": "cards", "modal": "organizations"}, {"id": "events", "label": "事件时间线", "dataset": "events", "layout": "timeline", "modal": "events"}, {"id": "narratives", "label": "叙事矩阵", "dataset": "narratives", "layout": "matrix", "modal": "narratives"}, {"id": "corpus", "label": "叙事语料", "dataset": "corpus", "layout": "cards", "modal": "corpus"}, {"id": "relationships", "label": "关系图谱", "dataset": "relationships", "layout": "network", "modal": "relationships"}, {"id": "platforms", "label": "平台传播", "dataset": "platforms", "layout": "cards", "modal": "platforms"}, {"id": "public_opinion", "label": "民意基础", "dataset": "public_opinion", "layout": "evidence", "modal": "public_opinion"}, {"id": "finance_resources", "label": "资源能力", "dataset": "finance_resources", "layout": "cards", "modal": "finance_resources"}, {"id": "disputes_sources", "label": "争议与来源", "dataset": "disputes", "layout": "split", "modal": "disputes"}, {"id": "research_doc", "label": "研究文档摘编", "dataset": "research_sections", "layout": "document", "modal": null}], "global_filters": [{"id": "faction", "label": "阵营", "type": "multi_select", "options": ["亚裔/华裔反新冷战派", "人权与台湾主体性左翼/反威权左翼", "制度内民主社会主义派", "反战反帝国主义派", "无政府主义、Antifa与黑块式网络", "校园亲巴勒斯坦与反殖民左翼", "气候正义与环境直接行动派", "种族正义与废除主义派", "选举进步派与民主党左翼外围", "革命社会主义/共产主义组织派"]}, {"id": "organization_type", "label": "组织类型", "type": "select", "options": ["abolitionist organization", "anti-Zionist Jewish advocacy organization", "anti-new-cold-war advocacy network", "antiwar NGO / global peace network", "antiwar coalition", "antiwar feminist grassroots organization", "candidate recruitment / progressive PAC-style organization", "communist party", "diasporic Chinese left media/education collective", "global climate movement organization", "left movement space / education venue", "membership organization / democratic socialist", "nonviolent direct action climate movement", "online racial justice advocacy organization", "peace organization / veterans network", "progressive advocacy / petition platform", "progressive electoral party / ballot line organization", "racial justice coalition", "revolutionary socialist party / party media network", "student Palestine solidarity network", "student/youth network", "youth climate movement"]}, {"id": "relevance", "label": "涉华涉台相关度", "type": "select", "options": ["中等:通过反新冷战、反军工、台湾主体性内部讨论进入", "中高:反战动员中出现No war with China和亚太基地议题", "中高:有台湾与南海军事对抗、冲绳基地等文章", "中高:设有China Working Group并提到佩洛西访台抗议", "中:不一定频繁涉台,但在中国/关税/新冷战议题上有公开文章", "中:曾有请愿要求佩洛西重新考虑访台风险", "中:通过中国社会主义、反帝论坛和左翼教育议题关联", "低到中:以校园政治为通道,涉台多为间接", "低到中:通过国会进步派候选人和外交预Line truncated
<script>
const DATA = JSON.parse(document.getElementById('data-json').textContent);
const D = DATA.datasets;
const sourceIndex = Object.fromEntries((D.sources||[]).map(s=>[s.id,s]));
const orgIndex = Object.fromEntries((D.organizations||[]).map(o=>[o.id,o]));
const state = {tab:'overview', q:'', faction:'', relevance:'', confidence:'', year:''};
const tabDesc = {
overview:'总览数据规模、核心发现、研究边界、补充摘要和仍需补充的数据缺口。',
factions:'展示美国左翼/进步派生态的阵营分类、社会基础、组织能力和涉华涉台立场。',
organizations:'展示组织画像、公开结构、涉华涉台活动、组织深描、争议入口和资料来源。',
events:'按时间线展示涉华涉台及关联事件,突出触发因素、组织反应、研究价值和争议。',
narratives:'展示叙事框架、道德入口、接受与反弹结构、证据要求和安全边界。',
corpus:'展示公开声明、文章、活动页和短摘录语料,用于支撑叙事分析。',
relationships:'展示组织—组织、组织—议题之间的关系边、证据基础和简易网络图。',
platforms:'展示社交平台、组织官网和独立媒体的传播适配、内容形式和研究指标。',
public_opinion:'展示民意、社会基础和受众背景,用来解释叙事为什么有进入空间或反弹风险。',
finance_resources:'展示公开财务与资源能力信息,同时明确禁止用途。',
disputes_sources:'展示争议、反驳、研究处理方式和来源可信度注册表。',
research_doc:'尽量保留 v2 Markdown 的原始研究知识,以文档摘编形式完整展示。'
};
const titleFields = {
factions:'阵营名称', organizations:'显示标题', events:'事件标题', narratives:'叙事名称', corpus:'中文摘要', relationships:'关系类型', platforms:'平台名称', public_opinion:'主题', finance_resources:'组织名称', disputes:'争议标题', sources:'标题'
};
function esc(x){return String(x??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
function arr(v){ if(!v) return []; if(Array.isArray(v)) return v.filter(x=>x!==''&&x!=null); return [v]; }
function isEmpty(v){return v==null || v==='' || (Array.isArray(v)&&v.length===0);}
function textOf(v){ if(Array.isArray(v)) return v.map(textOf).join(' '); if(v&&typeof v==='object') return Object.values(v).map(textOf).join(' '); return String(v??''); }
function orgName(id){ return orgIndex[id]?.['组织名称'] || orgIndex[id]?.['显示标题'] || id; }
function tagList(items, cls=''){ const a=arr(items); if(!a.length) return ''; return `<div class="list">${a.map(x=>`<span class="chip ${cls}">${esc(formatToken(x))}</span>`).join('')}</div>`; }
function formatToken(x){ if(orgIndex[x]) return orgName(x); return String(x??''); }
function fieldValue(v, key=''){
if(isEmpty(v)) return '<span style="color:#967979">暂无记录</span>';
if(Array.isArray(v)){
if(key.includes('来源')) return v.map(sourceCard).join('') || '<span style="color:#967979">暂无来源</span>';
return '<ul>'+v.map(x=>`<li>${esc(formatToken(x))}</li>`).join('')+'</ul>';
}
const s=String(v);
if(/^https?:\/\//.test(s)) return `<a href="${esc(s)}" target="_blank" rel="noopener">${esc(s)}</a>`;
return `<p>${esc(s).replace(/\n/g,'<br>')}</p>`;
}
function sourceCard(idOrObj){
const sid = typeof idOrObj==='string'?idOrObj:(idOrObj?.id||idOrObj?.source_id||'');
const s = sourceIndex[sid];
if(!s) return `<div class="source-card"><div class="source-title">${esc(sid || textOf(idOrObj))}</div><div class="source-meta">未在来源表中展开,建议后续补齐。</div></div>`;
const link = s['链接'] ? `<a href="${esc(s['链接'])}" target="_blank" rel="noopener">打开来源</a>` : '无链接';
return `<div class="source-card"><div class="source-title">${esc(s['标题']||s.id)}</div><div class="source-meta">${esc(s['来源类型']||'')} · 可信度:${esc(s['可信度']||'')} · ${link}</div>${s['说明']?`<div class="source-meta">${esc(s['说明'])}</div>`:''}</div>`;
}
function init(){
document.getElementById('pageTitle').textContent = DATA.meta.title;
document.getElementById('pageSubtitle').textContent = DATA.meta.subtitle + '。' + (DATA.meta.enhancement_note||'');
const b = D.safety_boundary || {};
document.getElementById('boundaryPills').innerHTML = ['公开资料研究','分区式详情页','完整文档摘编','可检索数据'].map((x,i)=>`<span class="pill ${i===0?'strong':''}">${x}</span>`).join('') + (b.allowed_scope? b.allowed_scope.slice(0,4).map(x=>`<span class="pill">${esc(x)}</span>`).join(''):'');
document.getElementById('tabs').innerHTML = DATA.tabs.map(t=>`<button class="tab ${t.id===state.tab?'active':''}" onclick="switchTab('${t.id}')">${esc(t.label)}</button>`).join('');
populateFilters(); bind(); render();
}
function bind(){
document.getElementById('q').addEventListener('input',e=>{state.q=e.target.value.trim(); render();});
['factionFilter','relevanceFilter','confidenceFilter','yearFilter'].forEach(id=>document.getElementById(id).addEventListener('change',e=>{state[id.replace('Filter','')]=e.target.value; render();}));
document.getElementById('resetBtn').onclick=()=>{state.q='';state.faction='';state.relevance='';state.confidence='';state.year='';document.querySelectorAll('.toolbar input,.toolbar select').forEach(el=>el.value='');render();};
}
function populateFilters(){
const factions=[...new Set((D.organizations||[]).flatMap(o=>arr(o['所属阵营'])).filter(Boolean))].sort();
const relevance=[...new Set((D.organizations||[]).map(o=>o['涉华涉台相关度']).filter(Boolean))].sort();
const conf=[...new Set(Object.values(D).flatMap(ds=>Array.isArray(ds)?ds.map(x=>x['来源可信度']||x['可信度']||x['补充来源可信度']).filter(Boolean):[]))].sort();
const years=[...new Set((D.events||[]).concat(D.corpus||[]).map(x=>x['年份']).filter(Boolean))].sort().reverse();
fillSelect('factionFilter',factions); fillSelect('relevanceFilter',relevance); fillSelect('confidenceFilter',conf); fillSelect('yearFilter',years);
}
function fillSelect(id, vals){ const el=document.getElementById(id); const first=el.options[0].outerHTML; el.innerHTML=first+vals.map(v=>`<option value="${esc(v)}">${esc(v)}</option>`).join('');}
function switchTab(id){state.tab=id;document.querySelectorAll('.tab').forEach(b=>b.classList.toggle('active',b.textContent===DATA.tabs.find(t=>t.id===id).label));render();}
function datasetForTab(){ const t=DATA.tabs.find(x=>x.id===state.tab); return t?.dataset; }
function filtered(items){
return items.filter(item=>{
const txt=textOf(item).toLowerCase();
if(state.q && !txt.includes(state.q.toLowerCase())) return false;
if(state.faction && !arr(item['所属阵营']).includes(state.faction) && !arr(item['易接受阵营']).includes(state.faction) && !arr(item['易反弹阵营']).includes(state.faction)) return false;
if(state.relevance && item['涉华涉台相关度']!==state.relevance) return false;
if(state.confidence && ![item['来源可信度'],item['可信度'],item['补充来源可信度']].includes(state.confidence)) return false;
if(state.year && item['年份']!==state.year) return false;
return true;
});
}
function panelHead(label, desc, count){return `<div class="panel-head"><div><h2 class="panel-title">${esc(label)}</h2><p class="panel-desc">${esc(desc||'')}</p></div>${count!=null?`<div class="count-badge">${count} 条</div>`:''}</div>`;}
function render(){
document.getElementById('tabs').innerHTML = DATA.tabs.map(t=>`<button class="tab ${t.id===state.tab?'active':''}" onclick="switchTab('${t.id}')">${esc(t.label)}</button>`).join('');
const t=DATA.tabs.find(x=>x.id===state.tab); const panel=document.getElementById('panel');
if(state.tab==='overview'){ panel.innerHTML=renderOverview(); return; }
if(state.tab==='research_doc'){ panel.innerHTML=renderDocument(); return; }
if(state.tab==='disputes_sources'){ panel.innerHTML=renderSplit(t); return; }
const data=filtered(D[t.dataset]||[]);
let body='';
if(t.layout==='timeline') body=renderTimeline(data,t.modal);
else if(t.layout==='matrix') body=renderMatrix(data,t.modal);
else if(t.layout==='network') body=renderNetwork(data,t.modal);
else body=renderCards(data,t.dataset,t.modal);
panel.innerHTML=panelHead(t.label,tabDesc[t.id],data.length)+body;
}
function renderOverview(){
const counts=DATA.dashboard.counts||{}; const summary=DATA.dashboard.completion_summary||{}; const safety=DATA.dashboard.safety_boundary||{};
const stats=[['阵营',counts.factions],['组织',counts.organizations],['事件',counts.events],['叙事框架',counts.narratives],['语料',counts.corpus],['关系边',counts.relationships],['来源',counts.sources],['研究文档节',counts.research_sections]];
return panelHead('专题总览',tabDesc.overview,null)+`
<div class="stat-grid">${stats.map(([k,v])=>`<div class="stat"><div class="num">${esc(v??0)}</div><div class="lab">${esc(k)}</div></div>`).join('')}</div>
<div class="section-grid">
<div>
<div class="info-box"><h3>核心发现</h3><ul>${(DATA.dashboard.core_findings||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul></div>
<div class="info-box"><h3>v2 补充摘要</h3><div class="grid two">${Object.entries(summary).map(([k,v])=>`<div class="card" style="cursor:default"><h3>${esc(k)}</h3><div class="summary">新增/补充 ${esc(v)} 条</div></div>`).join('')}</div></div>
</div>
<div>
<div class="info-box"><h3>研究边界</h3><p><b>允许范围:</b></p>${tagList(safety.allowed_scope,'green')}<p><b>排除内容:</b></p>${tagList(safety.excluded,'red')}<p>${esc(safety.terminology_rule||'')}</p></div>
<div class="info-box"><h3>后续数据缺口</h3><ul>${(DATA.dashboard.remaining_gaps_v2||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul></div>
</div>
</div>`;
}
function cardTitle(item, ds){return item[titleFields[ds]] || item['标题'] || item['id'];}
function cardSummary(item, ds){
const keys={factions:['涉华涉台立场','组织能力'], organizations:['公开自我描述','涉华涉台活动','组织能力判断'], events:['触发因素','组织反应','研究价值','结果'], narratives:['核心主张','风险说明'], corpus:['中文摘要','原文短摘'], platforms:['适配场景','涉华涉台用途'], public_opinion:['最新观察','研究相关性'], finance_resources:['已知公开信息','研究用途'], disputes:['研究处理方式','主张A','主张B'], sources:['说明']};
return (keys[ds]||[]).map(k=>item[k]).filter(Boolean).join(' ');
}
function renderCards(items, ds, modal){
if(!items.length) return '<div class="empty">没有匹配结果</div>';
return `<div class="grid">${items.map(item=>`<article class="card" onclick='openModal("${modal||ds}",${JSON.stringify(item.id)})'><h3>${esc(cardTitle(item,ds))}</h3><div class="meta">${esc(item['组织类型']||item['阵营类型']||item['事件类型']||item['平台名称']||item['来源类型']||'')}</div><div class="summary">${esc(cardSummary(item,ds))}</div>${renderCardChips(item)}</article>`).join('')}</div>`;
}
function renderCardChips(item){
const chips=[]; ['所属阵营','涉华涉台相关度','来源可信度','可信度','争议等级','年份'].forEach(k=>{if(item[k]) chips.push(...arr(item[k]).slice(0,3));});
if(item['议题焦点']) chips.push(...arr(item['议题焦点']).slice(0,3));
if(item['叙事标签']) chips.push(...arr(item['叙事标签']).slice(0,3));
return tagList(chips.slice(0,8));
}
function renderTimeline(items, modal){
if(!items.length) return '<div class="empty">没有匹配事件</div>';
return `<div class="timeline">${items.map(item=>`<div class="titem" onclick='openModal("${modal}",${JSON.stringify(item.id)})'><div class="tdate">${esc(item['日期']||'未注明日期')}</div><h3>${esc(item['事件标题'])}</h3><div class="summary">${esc(cardSummary(item,'events'))}</div>${tagList([item['事件类型'],...(arr(item['叙事标签']).slice(0,4)), item['来源可信度']].filter(Boolean))}</div>`).join('')}</div>`;
}
function renderMatrix(items, modal){
if(!items.length) return '<div class="empty">没有匹配叙事</div>';
return `<div class="matrix">${items.map(item=>`<div class="matrix-card" onclick='openModal("${modal}",${JSON.stringify(item.id)})'><h3>${esc(item['叙事名称'])}</h3><div class="summary">${esc(item['核心主张'])}</div><div class="kv"><b>道德入口</b><div>${tagList(item['道德入口'])}</div><b>争议等级</b><div>${esc(item['争议等级']||'')}</div></div></div>`).join('')}</div>`;
}
function renderNetwork(items, modal){
const top=items.slice(0,50); const nodes=[]; const seen=new Set(); top.forEach(e=>{[e['源节点'],e['目标节点']].forEach(n=>{if(n&&!seen.has(n)){seen.add(n);nodes.push(n)}})});
const N=Math.min(nodes.length,24); const cx=520,cy=160,R=125; const pos={}; nodes.slice(0,N).forEach((n,i)=>{pos[n]=[cx+R*Math.cos(2*Math.PI*i/N),cy+R*Math.sin(2*Math.PI*i/N)]});
const svg=`<div class="network"><svg viewBox="0 0 1040 320">${top.map(e=>pos[e['源节点']]&&pos[e['目标节点']]?`<line class="edge" x1="${pos[e['源节点']][0]}" y1="${pos[e['源节点']][1]}" x2="${pos[e['目标节点']][0]}" y2="${pos[e['目标节点']][1]}"/>`:'' ).join('')}${nodes.slice(0,N).map(n=>`<g><circle class="node" cx="${pos[n][0]}" cy="${pos[n][1]}" r="24"/><text class="nodeText" text-anchor="middle" x="${pos[n][0]}" y="${pos[n][1]+4}">${esc(orgName(n)).slice(0,14)}</text></g>`).join('')}</svg></div>`;
const table=`<div class="table-wrap"><table><thead><tr><th>源节点</th><th>目标节点</th><th>关系类型</th><th>议题语境</th><th>强度</th><th>可信度</th></tr></thead><tbody>${items.map(item=>`<tr onclick='openModal("${modal}",${JSON.stringify(item.id)})'><td>${esc(item['源节点名称'])}</td><td>${esc(item['目标节点名称'])}</td><td>${esc(item['关系类型'])}</td><td>${esc(item['议题语境'])}</td><td>${esc(item['强度']||'')}</td><td>${esc(item['来源可信度']||'')}</td></tr>`).join('')}</tbody></table></div>`;
return svg+table;
}
function renderSplit(t){
const disputes=filtered(D.disputes||[]); const sources=filtered(D.sources||[]);
return panelHead('争议与来源',tabDesc.disputes_sources,disputes.length+sources.length)+`<div class="split"><div><h3>争议与反驳记录</h3>${renderCards(disputes,'disputes','disputes')}</div><div><h3>来源注册表</h3><div class="table-wrap"><table><thead><tr><th>标题</th><th>类型</th><th>可信度</th><th>说明</th></tr></thead><tbody>${sources.map(s=>`<tr onclick='openModal("sources",${JSON.stringify(s.id)})'><td>${esc(s['标题'])}</td><td>${esc(s['来源类型'])}</td><td>${esc(s['可信度'])}</td><td>${esc(s['说明']||'')}</td></tr>`).join('')}</tbody></table></div></div></div>`;
}
function renderDocument(){
const sections=D.research_sections||[];
return panelHead('研究文档摘编',tabDesc.research_doc,sections.length)+`<div class="doc-layout"><div class="doc-nav">${sections.map(s=>`<button onclick="document.getElementById('${s.id}').scrollIntoView({behavior:'smooth',block:'start'})">${esc(s.title)}</button>`).join('')}</div><div class="doc-body">${sections.map(s=>`<section class="md-section" id="${s.id}"><h2>${esc(s.title)}</h2>${mdToHtml(s.markdown)}</section>`).join('')}</div></div>`;
}
function mdToHtml(md){
const lines=(md||'').split(/\n/); let out='', i=0;
while(i<lines.length){
const line=lines[i];
if(!line.trim()){i++; continue;}
if(line.startsWith('|')){
let rows=[]; while(i<lines.length && lines[i].startsWith('|')){rows.push(lines[i]); i++;}
const clean=rows.filter(r=>!/^[|\s:\-]+$/.test(r));
if(clean.length){ out+='<div class="md-table"><table>'+clean.map((r,idx)=>'<tr>'+r.split('|').slice(1,-1).map(c=>idx===0?`<th>${esc(c.trim())}</th>`:`<td>${esc(c.trim())}</td>`).join('')+'</tr>').join('')+'</table></div>'; }
continue;
}
if(/^###\s+/.test(line)){out+=`<h3>${esc(line.replace(/^###\s+/,''))}</h3>`;i++;continue;}
if(/^[-*]\s+/.test(line)){let items=[]; while(i<lines.length && /^[-*]\s+/.test(lines[i])){items.push(lines[i].replace(/^[-*]\s+/,''));i++;} out+='<ul>'+items.map(x=>`<li>${esc(x)}</li>`).join('')+'</ul>'; continue;}
out+=`<p>${esc(line)}</p>`; i++;
}
return out;
}
function openModal(ds, id){
let item=(D[ds]||[]).find(x=>x.id===id);
if(!item && ds==='sources') item=(D.sources||[]).find(x=>x.id===id);
if(!item) return;
const title=cardTitle(item,ds)||id; document.getElementById('modalTitle').textContent=title;
document.getElementById('modalSub').innerHTML=modalSubtitle(item,ds);
document.getElementById('modalBody').innerHTML=renderDetail(item,ds)+renderRelated(item,ds);
document.getElementById('modal').classList.add('show');
}
function modalSubtitle(item,ds){
const vals=[item['组织类型'],item['阵营类型'],item['事件类型'],item['平台名称'],item['来源类型'],item['日期'],item['来源可信度']||item['可信度']].filter(Boolean);
return vals.map(esc).join(' · ');
}
function renderDetail(item,ds){
const schema=DATA.rich_modal_schemas[ds]||[{title:'详情字段',fields:Object.keys(item).filter(k=>k!=='搜索文本')}];
let used=new Set();
let html=schema.map(sec=>{
const fields=sec.fields.filter(k=>!isEmpty(item[k])); fields.forEach(k=>used.add(k));
if(!fields.length) return '';
return `<section class="detail-section"><h3>${esc(sec.title)}</h3>${fields.map(k=>`<div class="field"><div class="field-label">${esc(k)}</div><div class="field-value">${fieldValue(item[k],k)}</div></div>`).join('')}</section>`;
}).join('');
const rest=Object.keys(item).filter(k=>!used.has(k)&&!['id','搜索文本','显示标题'].includes(k)&&!isEmpty(item[k]));
if(rest.length){ html+=`<section class="detail-section"><h3>其他补充字段</h3>${rest.map(k=>`<div class="field"><div class="field-label">${esc(k)}</div><div class="field-value">${fieldValue(item[k],k)}</div></div>`).join('')}</section>`; }
return html;
}
function renderRelated(item,ds){
let blocks=[];
if(ds==='organizations'){
const oid=item.id;
const ev=(D.events||[]).filter(e=>arr(e['涉及组织']).includes(oid)).slice(0,6);
const rel=(D.relationships||[]).filter(r=>r['源节点']===oid||r['目标节点']===oid).slice(0,8);
const corp=(D.corpus||[]).filter(c=>c['来源组织']===oid).slice(0,6);
blocks.push(relatedCards('关联事件',ev,'events','事件标题',['日期','研究价值','触发因素']));
blocks.push(relatedCards('关系边',rel,'relationships','关系类型',['源节点名称','目标节点名称','议题语境']));
blocks.push(relatedCards('相关语料',corp,'corpus','中文摘要',['日期','平台','反弹风险']));
}
if(ds==='events'){
const tags=arr(item['叙事标签']).concat(arr(item['议题框架']));
const ns=(D.narratives||[]).filter(n=>tags.some(t=>textOf(n).includes(t))).slice(0,6);
blocks.push(relatedCards('相关叙事框架',ns,'narratives','叙事名称',['核心主张','风险说明']));
}
if(ds==='narratives'){
const name=item['叙事名称'];
const corp=(D.corpus||[]).filter(c=>arr(c['叙事标签']).some(t=>name.includes(t)||t.includes(name)||item['核心主张']?.includes(t))).slice(0,8);
blocks.push(relatedCards('支撑语料样本',corp,'corpus','中文摘要',['来源组织名称','日期','反弹风险']));
}
return blocks.filter(Boolean).join('');
}
function relatedCards(title,items,ds,titleKey,fields){
if(!items.length) return '';
return `<section class="detail-section related-block"><h3>${esc(title)}</h3><div class="subcards">${items.map(it=>`<div class="subcard" onclick='openModal("${ds}",${JSON.stringify(it.id)})'><h4>${esc(it[titleKey]||it.id)}</h4><p>${esc(fields.map(f=>it[f]).filter(Boolean).join(' · ')).slice(0,220)}</p></div>`).join('')}</div></section>`;
}
function closeModal(){document.getElementById('modal').classList.remove('show');}
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal()});
init();
</script>
</body>
</html>
@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>美方新右翼与极右翼群体研究专题库 v3</title>
<style>
:root{
--bg:#f4f5f7; --card:#fff; --ink:#1f2933; --muted:#657282; --line:#e1e4e8;
--red:#7a1f1f; --red2:#9f2b2b; --softred:#fff4f4; --accent:#b84632;
--green:#2e7d32; --yellow:#b7791f; --orange:#c05621; --high:#c62828; --severe:#5f0f0f;
--shadow:0 14px 36px rgba(31,41,51,.12);
}
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;line-height:1.6}
.header{background:linear-gradient(135deg,#6b1717,#9c2f2f 64%,#c25a42);color:#fff;padding:32px 28px 26px;border-bottom:6px solid rgba(255,255,255,.18)}
.header-inner{max-width:1440px;margin:auto}.header h1{margin:0 0 8px;font-size:30px;letter-spacing:.5px}.header p{margin:0;color:rgba(255,255,255,.88);font-size:15px;max-width:1100px}.top-pills{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
.app{max-width:1440px;margin:0 auto;padding:20px 24px 38px}.toolbar{background:#fff;border:1px solid var(--line);border-radius:18px;padding:14px;box-shadow:0 8px 22px rgba(31,41,51,.06);position:sticky;top:0;z-index:20}
.search-row{display:grid;grid-template-columns:minmax(280px,1fr) 170px 190px 140px 96px;gap:10px}.input,.select{width:100%;border:1px solid #d7dce2;border-radius:12px;padding:11px 12px;background:#fff;color:var(--ink);outline:none}.input:focus,.select:focus{border-color:#a93a31;box-shadow:0 0 0 3px rgba(169,58,49,.12)}
.btn{border:0;border-radius:12px;padding:10px 14px;background:var(--red);color:#fff;cursor:pointer;font-weight:700}.btn.secondary{background:#f1f3f5;color:#334155;border:1px solid #dbe1e8}.btn:hover{filter:brightness(.96)}
.tabs{display:flex;gap:8px;overflow-x:auto;padding:14px 0 4px}.tab{white-space:nowrap;border:1px solid #e1d0d0;background:#fff;color:#5f1d1d;border-radius:999px;padding:9px 13px;cursor:pointer;font-weight:700}.tab.active{background:var(--red);color:#fff;border-color:var(--red)}
.notice{border-radius:16px;padding:14px 16px;margin:14px 0;border:1px solid #ead7d7;background:#fffafa}.notice b{color:var(--red)}.notice.safe{background:#fbfff7;border-color:#d9eac8}.notice.warn{background:#fffaf0;border-color:#f0dfb8}.notice.danger{background:#fff4f4;border-color:#eac2c2}
.section-title{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin:22px 0 12px}.section-title h2{font-size:20px;margin:0;color:#5c1717}.section-title small{color:var(--muted)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px}.grid.two{grid-template-columns:repeat(auto-fit,minmax(400px,1fr))}.grid.three{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:14px}.stat,.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:0 8px 22px rgba(31,41,51,.05);position:relative}.stat .label{color:var(--muted);font-size:13px}.stat .value{font-size:24px;font-weight:900;color:#7a1f1f;margin:6px 0}.stat .desc,.sub,.mini{color:var(--muted);font-size:13px}.card h3{margin:0 0 6px;font-size:18px;color:#253041}.card .kv{color:#4b5563;font-size:14px;margin:10px 0}.card-actions{display:flex;justify-content:space-between;align-items:center;margin-top:12px;gap:10px}.linkbtn{border:1px solid #d8caca;background:#fff;color:#7a1f1f;border-radius:10px;padding:7px 10px;cursor:pointer;font-weight:800}.linkbtn:hover{background:#fff4f4}
.tags{display:flex;flex-wrap:wrap;gap:7px}.pill{display:inline-flex;align-items:center;gap:5px;border-radius:999px;padding:4px 9px;background:#f1f3f5;color:#344054;font-size:12px;max-width:100%;word-break:break-word}.pill.red{background:#fff1f1;color:#7a1f1f}.pill.orange{background:#fff7ed;color:#9a3412}.pill.blue{background:#eef6ff;color:#075985}.pill.light{background:rgba(255,255,255,.16);color:#fff;border:1px solid rgba(255,255,255,.26)}
.risk{position:absolute;top:14px;right:14px;color:#fff;border-radius:999px;padding:4px 10px;font-size:12px;font-weight:900}.risk.low{background:var(--green)}.risk.mid{background:var(--yellow)}.risk.midhi{background:var(--orange)}.risk.high{background:var(--high)}.risk.severe{background:var(--severe)}
.chain{display:flex;flex-wrap:wrap;gap:8px;align-items:center}.step{border:1px solid #eadede;background:#fffafa;border-radius:12px;padding:7px 9px;font-size:13px}.arrow{color:#a33;font-weight:900}
.table-wrap{background:#fff;border:1px solid var(--line);border-radius:16px;overflow:auto;margin:10px 0}table{width:100%;border-collapse:collapse;font-size:14px}th{background:#faf3f3;color:#6b1717;text-align:left;position:sticky;top:0;z-index:1}th,td{padding:10px 12px;border-bottom:1px solid #edf0f3;vertical-align:top}td ul{margin:0;padding-left:18px}.timeline{border-left:3px solid #b84632;margin-left:12px}.timeline-item{margin:0 0 16px 18px;position:relative}.timeline-item:before{content:"";width:12px;height:12px;background:#b84632;border-radius:50%;position:absolute;left:-26px;top:22px;box-shadow:0 0 0 5px #f7dada}
.search-results{display:none;background:#fff;border:1px solid var(--line);border-radius:16px;margin-top:12px;max-height:330px;overflow:auto}.result-row{display:flex;justify-content:space-between;gap:12px;align-items:center;border-top:1px solid #eef0f2;padding:10px 12px}.result-row:first-child{border-top:0}.result-title{font-weight:900}.result-source{font-size:12px;color:var(--muted)}.empty{background:#fff;border:1px dashed #d2d8e0;border-radius:16px;padding:22px;text-align:center;color:var(--muted)}
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:100;padding:18px}.modal{width:min(1180px,96vw);max-height:92vh;background:#fff;border-radius:22px;box-shadow:var(--shadow);display:flex;flex-direction:column;overflow:hidden}.modal-head{padding:18px 20px;background:linear-gradient(135deg,#711818,#a5362f);color:#fff;display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.modal-head h2{margin:0;font-size:22px}.modal-sub{font-size:13px;color:rgba(255,255,255,.82);margin-top:4px}.modal-close{border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.14);color:#fff;border-radius:10px;padding:8px 10px;cursor:pointer;font-weight:800;white-space:nowrap}.modal-tabs{display:flex;gap:8px;overflow-x:auto;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff7f7}.modal-tab{border:1px solid #e2cfcf;background:#fff;color:#6b1717;border-radius:999px;padding:8px 12px;cursor:pointer;white-space:nowrap;font-weight:800}.modal-tab.active{background:#7a1f1f;color:#fff;border-color:#7a1f1f}.modal-body{padding:18px;overflow:auto}.detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}.detail-section{border:1px solid #e5e7eb;border-radius:16px;background:#fff;padding:14px}.detail-section h4{margin:0 0 8px;color:#6b1717}.detail-section p{margin:0;white-space:pre-line}.field-list{margin:0;padding-left:18px}.object-card{border:1px solid #e7eaf0;background:#fbfcfe;border-radius:14px;padding:10px;margin:8px 0}.object-card h5{margin:0 0 6px;color:#354052}.modal-grid-large{display:grid;grid-template-columns:1.2fr .8fr;gap:14px}.json-note{font-size:12px;color:rgba(255,255,255,.86);margin-top:6px}
@media(max-width:900px){.search-row{grid-template-columns:1fr}.grid.two,.grid.three,.modal-grid-large{grid-template-columns:1fr}.header h1{font-size:24px}.app{padding:14px}.modal{width:100vw;height:100vh;max-height:100vh;border-radius:0}.modal-backdrop{padding:0}.modal-head{flex-direction:column}.risk{position:static;display:inline-block;margin-bottom:8px}}
</style>
</head>
<body>
<header class="header"><div class="header-inner"><h1 id="pageTitle"></h1><p id="pageSubtitle"></p><div class="top-pills" id="metaPills"></div></div></header>
<main class="app">
<section class="toolbar">
<div class="search-row">
<input class="input" id="globalSearch" placeholder="全局搜索:组织、人物、平台、事件、叙事、涉华涉台议题……" />
<select class="select" id="riskFilter"><option value="">全部风险等级</option></select>
<select class="select" id="typeFilter"><option value="">全部类型/节点</option></select>
<select class="select" id="sourceFilter"><option value="">全部来源</option></select>
<button class="btn secondary" id="resetBtn">重置</button>
</div>
<div id="searchResults" class="search-results"></div>
<nav class="tabs" id="tabs"></nav>
</section>
<div class="notice" id="tabDesc"></div>
<section id="content"></section>
</main>
<div class="modal-backdrop" id="modalBackdrop">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-head">
<div><h2 id="modalTitle"></h2><div class="modal-sub" id="modalSubtitle"></div><div class="json-note">详情页为结构化展示;原始 JSON 仅可通过按钮复制。</div></div>
<div style="display:flex;gap:8px;align-items:center"><button class="modal-close" id="copyJsonBtn">复制本条JSON</button><button class="modal-close" id="closeModal">关闭</button></div>
</div>
<div class="modal-tabs" id="modalTabs"></div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<script>
const DATA = {"meta": {"title": "美方新右翼与极右翼群体研究专题库", "subtitle": "公开资料研究|组织谱系|叙事结构|平台生态|地方嵌入|涉华涉台议题", "version": "v3.0-full-structured-no-blank", "language": "zh-CN", "lastUpdated": "2026-06-25", "researchScope": ["美国新右翼政策网络", "极右翼街头组织", "反政府民兵", "白人民族主义组织", "阴谋论运动", "地方文化战生态", "涉华涉台右翼叙事"], "researchBoundary": "本专题库仅用于公开资料研究、政策分析、舆情观察和风险防护,不用于渗透、动员、骚扰、目标定位、动员招募、规避平台治理或暴力指导。", "methodNote": "所有条目均以公开资料研究为定位;风险等级是研究性提示,不等同于法律定性。新右翼、传统保守主义、宗教保守主义、共和党支持者与暴力极端主义必须严格区分。", "dataDesignNote": "v3 修复详情页空白问题:弹框改为按字段动态分组,只有存在内容的二级页才显示;同时增加“全部字段”结构化页,避免知识删减或字段遗漏。", "allowedUses": ["公开资料研究", "非操控性风险监测", "舆情趋势分析", "反极端主义预防", "政策建议", "公共沟通建议", "族群安全风险提示", "来源可信度审查"], "prohibitedUses": ["渗透、诱导、策反或操控任何群体", "制造、放大或投放欺骗性信息", "对特定个人进行画像、骚扰、跟踪、威胁或 doxxing", "规避平台治理、执法监测或安全机制", "提供暴力、破坏、骚乱组织、逃避执法或现场对抗的操作性指导", "针对族裔、宗教、政治立场、国籍、性别或性取向群体进行仇恨动员"], "coreResearchChain": ["概念区分", "历史演化", "主要群体与组织谱系", "人员结构与社会基础", "叙事结构与受众心理", "社交媒体与平台生态", "生态网络与影响通道", "涉华涉台议题嵌入路径", "风险行为链条", "地方社会与经济利益", "资源、法律与治理反应"], "interpretationPrinciple": "保守主义立场、共和党支持、反移民政策主张、反中共言论、支持台湾或反对美国介入台湾,本身均不等于极端主义;只有出现去人化、暴力正当化、具体目标指向、威胁或行动化迹象,才进入高风险研判。"}, "dictionaries": {"riskLevels": {"低": "普通政治表达、政策争议或低风险研究对象", "中": "存在议题极化、阴谋论倾向或平台扩散风险", "中-高": "存在群体泛化、目标个人化、地方冲突或跨平台扩散风险", "高": "存在反政府动员、仇恨叙事、线下行动化或暴力正当化风险", "严重": "存在现实暴力、模仿袭击、攻击者崇拜或明确威胁风险"}, "riskColors": {"低": "green", "中": "yellow", "中-高": "orange", "高": "red", "严重": "darkred"}, "sourceLevels": {"A": "法院、政府、国会、执法机构等官方公开文件", "B": "主流媒体或可靠地方媒体", "C": "研究机构、智库、倡议组织", "D": "学术论文、预印本、数据集", "E": "公开社交媒体样本,仅作为舆情线索", "F": "匿名论坛、截图、二手转述,只能作为待核查线索"}, "organizationTypes": ["新右翼政策网络", "极右翼街头组织", "反政府民兵", "白人民族主义组织", "阴谋论运动", "主权公民运动", "宗教民族主义网络", "男性至上主义生态", "反移民组织", "去中心化训练网络"], "platformTypes": ["主流社交平台", "替代社交平台", "匿名论坛", "长视频平台", "播客生态", "众筹平台", "地方社群平台", "邮件/通讯平台", "独立网站"], "researchModules": {"concepts_typology": "概念与谱系:区分新右翼、极右翼和暴力极端主义", "history_evolution": "历史演化:从白人至上、民兵、Tea Party、Alt-Right 到 MAGA 与新右翼制度化", "groups_organizations": "群体与组织:组织资料卡、行动形态与风险边界", "personnel_social_base": "人员与社会基础:受众分层、人员生态与地方嵌入", "narratives_psychology": "叙事与心理:解释世界、塑造敌人、吸引受众的叙事结构", "social_media_ecology": "社交媒体生态:引流、沉淀、强化、迁移和线下转化", "influence_networks": "生态网络与影响通道:思想、媒体、平台、地方、资金如何形成影响", "china_taiwan_issues": "涉华涉台议题:TikTok、芯片、疫情、留学生、芬太尼、台湾和台海", "local_society_economics": "地方社会与经济利益:学校、教会、县级政治、注意力经济与法律基金"}, "riskBehaviorSignals": ["明确敌人 + 末日紧迫感", "去人化语言 + 行动号召", "目标地点 + 时间窗口", "个人 grievance + 武器文化 + 社群鼓励", "过去袭击者崇拜 + 模仿语言", "执法/法院不合法叙事 + 被捕者殉道化"], "sourLine truncated
let activeTab = 'dashboard';
let currentModalItem = null; let currentModalKey = null; let currentModalGroup = null;
const KEY_TO_TITLE = Object.fromEntries(DATA.tabs.map(t=>[t.dataKey,t.name]));
const SEARCH_KEYS = (DATA.queryConfig && DATA.queryConfig.searchKeys) || DATA.tabs.map(t=>t.dataKey).filter(k=>Array.isArray(DATA[k]));
const riskClass = r => ({'低':'low','中':'mid','中-高':'midhi','高':'high','严重':'severe'}[r] || '');
function esc(s){ return String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
function arr(v){ return Array.isArray(v) ? v : (v === undefined || v === null || v === '' ? [] : [v]); }
function isEmpty(v){ return v === undefined || v === null || v === '' || (Array.isArray(v)&&v.length===0) || (typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).length===0); }
function titleOf(item){ return item['名称'] || item['事件'] || item['议题'] || item['平台'] || item['场景'] || item['受众类型'] || item['阶段'] || item['概念'] || item['类型'] || item.label || item.id || '条目'; }
function subOf(item){ return item['中文名称'] || item['英文'] || item['事件类型'] || item['议题类型'] || item['平台类型'] || item['节点类型'] || item['时间'] || item['基本定位'] || item.description || ''; }
function riskBadge(r){ return r ? `<span class="risk ${riskClass(r)}">${esc(r)}</span>` : ''; }
function tags(list, cls=''){ return arr(list).slice(0,18).map(x=>`<span class="pill ${cls}">${esc(typeof x==='object' ? objectSummary(x) : x)}</span>`).join(''); }
function objectSummary(o){ return Object.entries(o).map(([k,v])=>`${k}:${Array.isArray(v)?v.join('、'):v}`).join(';'); }
function init(){
document.getElementById('pageTitle').textContent = DATA.meta.title || '专题库';
document.getElementById('pageSubtitle').textContent = (DATA.meta.subtitle||'') + '。' + (DATA.meta.interpretationPrinciple||'');
document.getElementById('metaPills').innerHTML = [`版本:${DATA.meta.version}`,`更新:${DATA.meta.lastUpdated}`,'11个研究栏目','结构化详情页','无空白二级页'].map(x=>`<span class="pill light">${esc(x)}</span>`).join('');
renderFilters(); renderTabs(); bindEvents(); render();
}
function renderFilters(){
const rf=document.getElementById('riskFilter'); Object.keys(DATA.dictionaries.riskLevels||{}).forEach(r=>rf.insertAdjacentHTML('beforeend',`<option>${esc(r)}</option>`));
const sf=document.getElementById('sourceFilter'); Object.keys(DATA.dictionaries.sourceLevels||{}).forEach(s=>sf.insertAdjacentHTML('beforeend',`<option>${esc(s)}</option>`));
updateTypeFilter();
}
function updateTypeFilter(){
const sel=document.getElementById('typeFilter'); const old=sel.value; sel.innerHTML='<option value="">全部类型/节点</option>';
const tab=DATA.tabs.find(t=>t.id===activeTab); const d=DATA[tab.dataKey]; const set=new Set();
if(Array.isArray(d)) d.forEach(it=>['类型','节点类型','平台类型','事件类型','议题类型','受众类型','场景','概念'].forEach(k=>{if(it[k]) set.add(it[k]);}));
[...set].sort().forEach(v=>sel.insertAdjacentHTML('beforeend',`<option>${esc(v)}</option>`));
if([...set].includes(old)) sel.value=old;
}
function renderTabs(){ document.getElementById('tabs').innerHTML = DATA.tabs.map(t=>`<button class="tab ${t.id===activeTab?'active':''}" data-tab="${t.id}">${esc(t.name)}</button>`).join(''); }
function bindEvents(){
document.getElementById('tabs').addEventListener('click', e=>{if(e.target.dataset.tab){activeTab=e.target.dataset.tab; renderTabs(); updateTypeFilter(); render();}});
['globalSearch','riskFilter','typeFilter','sourceFilter'].forEach(id=>document.getElementById(id).addEventListener('input',()=>{ renderSearch(); render(); }));
document.getElementById('resetBtn').onclick=()=>{['globalSearch','riskFilter','typeFilter','sourceFilter'].forEach(id=>document.getElementById(id).value=''); document.getElementById('searchResults').style.display='none'; render();};
document.getElementById('closeModal').onclick=closeModal;
document.getElementById('modalBackdrop').addEventListener('click', e=>{if(e.target.id==='modalBackdrop') closeModal();});
document.getElementById('copyJsonBtn').onclick=()=>{ if(currentModalItem) navigator.clipboard.writeText(JSON.stringify(currentModalItem,null,2)); };
document.getElementById('modalTabs').addEventListener('click', e=>{ if(e.target.dataset.group){currentModalGroup=e.target.dataset.group; renderModalBody();} });
}
function itemText(it){ return JSON.stringify(it).toLowerCase(); }
function filterData(items){
if(!Array.isArray(items)) return items;
const q=document.getElementById('globalSearch').value.trim().toLowerCase(); const risk=document.getElementById('riskFilter').value; const type=document.getElementById('typeFilter').value; const source=document.getElementById('sourceFilter').value;
return items.filter(it=>{
if(q && !itemText(it).includes(q)) return false;
if(risk && it['风险等级']!==risk) return false;
if(source && !arr(it['来源等级']).includes(source)) return false;
if(type){ const vals=['类型','节点类型','平台类型','事件类型','议题类型','受众类型','场景','概念'].map(k=>it[k]).filter(Boolean); if(!vals.includes(type)) return false; }
return true;
});
}
function render(){
const tab=DATA.tabs.find(t=>t.id===activeTab); document.getElementById('tabDesc').innerHTML = `<b>${esc(tab.name)}</b>:${esc(tab.description||'')}`;
const raw=DATA[tab.dataKey]; const filtered=filterData(raw);
if(tab.dataKey==='dashboard') return renderDashboard(raw);
if(tab.layout==='timeline' || tab.layout==='timelineTable') return renderTimeline(filtered, tab.dataKey);
if(tab.layout==='tableCard') return renderTableCards(filtered, tab.dataKey);
return renderCards(filtered, tab.dataKey, tab.layout);
}
function renderDashboard(d){
const html = [];
html.push(`<div class="overview-grid">${d.summaryCards.map(c=>`<div class="stat"><div class="label">${esc(c.label)}</div><div class="value">${esc(c.value)}${c.unit?`<span style="font-size:14px">${esc(c.unit)}</span>`:''}</div><div class="desc">${esc(c.description||'')}</div></div>`).join('')}</div>`);
html.push(`<div class="notice safe"><b>研究边界:</b>${esc(DATA.meta.researchBoundary)}<br><b>判读原则:</b>${esc(DATA.meta.interpretationPrinciple)}</div>`);
html.push(`<div class="grid two"><div class="card"><h3>热点议题</h3><div class="tags">${tags(d.hotIssues,'red')}</div></div><div class="card"><h3>重点风险关注</h3>${listBlock(d.riskFocus)}</div></div>`);
html.push(section('核心研究问题','来自原 Skill 的研究框架'));
html.push(`<div class="grid three">${d.coreQuestions.map(q=>`<div class="card"><h3>${esc(q.title)}</h3><p class="sub">${esc(q.content)}</p></div>`).join('')}</div>`);
html.push(section('风险行为链条模型','结构化展示,不提供操作性行动方案'));
html.push(`<div class="grid">${d.riskBehaviorModels.map(m=>`<div class="card"><h3>${esc(m.name)}</h3><div class="chain">${m.steps.map((s,i)=>`${i?'<span class="arrow">→</span>':''}<span class="step">${esc(s)}</span>`).join('')}</div></div>`).join('')}</div>`);
html.push(section('八类核心叙事模块','叙事-受众-风险点'));
html.push(simpleTable(d.narrativeModules,['叙事','核心内容','主要受众','风险点']));
html.push(section('六层生态网络','思想、媒体、平台、地方、街头、资源'));
html.push(simpleTable(d.ecosystemLayers,['层级','功能','典型节点']));
html.push(section('来源材料地图','对应原 Skill references/source_materials_map.md'));
html.push(renderObjectCards(d.sourceMaterialsMap,'来源类型'));
html.push(section('公开资料研究检查清单','对应原 Skill references/open_source_research_checklist.md'));
html.push(`<div class="grid three">${Object.entries(d.openSourceResearchChecklist||{}).map(([k,v])=>`<div class="card"><h3>${esc(k)}</h3>${listBlock(v)}</div>`).join('')}</div>`);
html.push(section('标准报告模板','对应原 Skill 的标准输出结构与模板'));
html.push(renderObjectCards(d.standardTemplates,'模板'));
html.push(section('未来趋势判断框架','对应原 Skill 的未来十年趋势判断框架'));
html.push(`<div class="card">${listBlock(d.futureTrendFramework)}</div>`);
html.push(`<div class="grid two" style="margin-top:14px"><div class="card"><h3>最小可用研判公式</h3><p class="sub" style="font-weight:900;color:#7a1f1f">${esc(d.minimumAssessmentFormula)}</p>${listBlock(d.formulaInterpretation)}</div><div class="card"><h3>事件触发覆盖范围</h3><div class="tags">${tags(d.triggerEventCoverage,'orange')}</div></div></div>`);
document.getElementById('content').innerHTML = html.join('');
}
function section(h,small=''){return `<div class="section-title"><h2>${esc(h)}</h2><small>${esc(small)}</small></div>`;}
function renderCards(items,key,layout){
const content=document.getElementById('content'); if(!items.length){content.innerHTML='<div class="empty">没有匹配的数据。请调整搜索或筛选条件。</div>';return;}
content.innerHTML = `<div class="grid">${items.map(it=>cardHTML(it,key)).join('')}</div>`; bindOpenButtons();
}
function pickTags(it){ return it['核心叙事'] || it['核心特征'] || it['主要议题'] || it['关联议题'] || it['易触发议题'] || it['典型内容'] || it['主要形式'] || it['外溢风险'] || it['代表现象'] || []; }
function cardHTML(it,key){
const title=titleOf(it), sub=subOf(it), risk=it['风险等级'];
const line1 = it['基本定位'] || it['公开身份'] || it['功能定位'] || it['研究意义'] || it['事件概况'] || it['研究价值'] || it['风险关注']?.[0] || '';
return `<article class="card">${riskBadge(risk)}<h3>${esc(title)}</h3><div class="sub">${esc(sub)}</div><div class="kv">${esc(String(line1)).slice(0,220)}</div><div class="tags">${tags(pickTags(it))}</div><div class="card-actions"><button class="linkbtn open-detail" data-id="${esc(it.id||title)}" data-key="${key}">查看结构化详情</button>${it['来源等级']?`<span class="mini">来源:${arr(it['来源等级']).join('/')}</span>`:''}</div></article>`;
}
function renderTimeline(items,key){
if(!items.length){document.getElementById('content').innerHTML='<div class="empty">没有匹配的数据。</div>';return;}
const rows=items.map(it=>`<div class="timeline-item"><div class="card">${riskBadge(it['风险等级'])}<h3>${esc(titleOf(it))}</h3><div class="sub">${esc(it['时间']||'')} ${it['地点']?'|'+esc(it['地点']):''}</div><div class="kv">${esc(it['研究意义']||it['事件类型']||it['事件概况']||'')}</div><div class="tags">${tags(it['主要特征']||it['核心叙事']||it['代表现象'])}</div><button class="linkbtn open-detail" data-id="${esc(it.id||titleOf(it))}" data-key="${key}">查看详情</button></div></div>`).join('');
document.getElementById('content').innerHTML=`<div class="timeline">${rows}</div>`; bindOpenButtons();
}
function renderTableCards(items,key){
if(!items.length){document.getElementById('content').innerHTML='<div class="empty">没有匹配的数据。</div>';return;}
document.getElementById('content').innerHTML = `<div class="table-wrap"><table><thead><tr><th>类型</th><th>对应生态</th><th>主要形式</th><th>相关议题</th><th>风险</th><th>操作</th></tr></thead><tbody>${items.map(it=>`<tr><td><b>${esc(it['类型'])}</b></td><td>${renderSimple(it['对应生态'])}</td><td>${renderSimple(it['主要形式'])}</td><td>${renderSimple(it['相关议题'])}</td><td>${it['风险等级']?`<span class="pill red">${esc(it['风险等级'])}</span>`:''}</td><td><button class="linkbtn open-detail" data-id="${esc(it.id||it['类型'])}" data-key="${key}">详情</button></td></tr>`).join('')}</tbody></table></div>`; bindOpenButtons();
}
function renderSimple(v){ return arr(v).map(x=>esc(typeof x==='object'?objectSummary(x):x)).join('、'); }
function simpleTable(rows, cols){ return `<div class="table-wrap"><table><thead><tr>${cols.map(c=>`<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${(rows||[]).map(r=>`<tr>${cols.map(c=>`<td>${renderValue(r[c])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`; }
function renderObjectCards(rows,titleKey){ return `<div class="grid">${(rows||[]).map((r,i)=>`<div class="card"><h3>${esc(r[titleKey] || titleOf(r) || ('条目 '+(i+1)))}</h3>${Object.entries(r).filter(([k])=>k!==titleKey).map(([k,v])=>`<div style="margin-top:9px"><b style="color:#6b1717">${esc(k)}:</b>${renderValue(v)}</div>`).join('')}</div>`).join('')}</div>`; }
function listBlock(list){ return `<ul>${arr(list).map(x=>`<li>${esc(typeof x==='object'?objectSummary(x):x)}</li>`).join('')}</ul>`; }
function bindOpenButtons(){ document.querySelectorAll('.open-detail').forEach(btn=>btn.onclick=()=>openDetail(btn.dataset.key, btn.dataset.id)); }
function openDetail(key,id){
const arrData=DATA[key]||[]; currentModalItem=arrData.find(x=>(x.id||titleOf(x))===id) || arrData.find(x=>titleOf(x)===id); currentModalKey=key;
if(!currentModalItem) return; const groups=availableGroups(currentModalItem); currentModalGroup=groups[0]?.id || 'all';
document.getElementById('modalTitle').textContent=titleOf(currentModalItem); document.getElementById('modalSubtitle').textContent=(KEY_TO_TITLE[key]||key) + (subOf(currentModalItem)?'|'+subOf(currentModalItem):'');
renderModalTabs(); renderModalBody(); document.getElementById('modalBackdrop').style.display='flex';
}
function closeModal(){ document.getElementById('modalBackdrop').style.display='none'; }
function groupDefs(){ return (DATA.detailConfig && DATA.detailConfig.groups) || []; }
function groupFields(item, group){
const keys = group.keys === '*' ? Object.keys(item) : group.keys;
return keys.filter(k=>!isEmpty(item[k]));
}
function assignedKeys(){
const s=new Set(); groupDefs().forEach(g=>{ if(g.keys !== '*') g.keys.forEach(k=>s.add(k)); }); return s;
}
function availableGroups(item){
const defs=groupDefs();
const groups=[];
defs.forEach(g=>{
if(g.keys==='*') return;
if(groupFields(item,g).length) groups.push(g);
});
// Always include all fields as a structured page, never blank, but not JSON raw.
groups.push(defs.find(g=>g.id==='all') || {id:'all',name:'全部字段',keys:'*'});
return groups;
}
function renderModalTabs(){
const groups=availableGroups(currentModalItem);
if(!groups.find(g=>g.id===currentModalGroup)) currentModalGroup=groups[0].id;
document.getElementById('modalTabs').innerHTML=groups.map(g=>`<button class="modal-tab ${g.id===currentModalGroup?'active':''}" data-group="${esc(g.id)}">${esc(g.name)}</button>`).join('');
}
function renderModalBody(){
renderModalTabs(); const group=availableGroups(currentModalItem).find(g=>g.id===currentModalGroup); let html='';
if(currentModalGroup==='boundary') html += `<div class="notice danger"><b>安全提醒:</b>${esc(DATA.meta.researchBoundary)}</div>`;
if(currentModalGroup==='all') {
const entries=Object.entries(currentModalItem).filter(([k,v])=>!isEmpty(v));
html += `<div class="notice warn"><b>全部字段结构化展示:</b>这一页不是 JSON 原文,而是将该条目的所有非空字段按字段卡片呈现,避免详情内容遗漏。</div>`;
html += `<div class="detail-grid">${entries.map(([k,v])=>fieldHTML(k,v)).join('')}</div>`;
} else {
const fields=groupFields(currentModalItem, group);
if(!fields.length) html += '<div class="empty">该分类没有可展示字段,已自动隐藏;请查看“全部字段”。</div>';
else html += `<div class="detail-grid">${fields.map(k=>fieldHTML(k,currentModalItem[k])).join('')}</div>`;
}
document.getElementById('modalBody').innerHTML=html;
}
function fieldHTML(k,v){ return `<section class="detail-section"><h4>${esc(k)}</h4><div class="field">${renderValue(v)}</div></section>`; }
function renderValue(v){
if(isEmpty(v)) return '<span class="mini">暂无数据</span>';
if(Array.isArray(v)){
if(v.every(x=>typeof x !== 'object')){
if(v.length<=12 && v.every(x=>String(x).length<60)) return `<div class="tags">${tags(v)}</div>`;
return `<ul class="field-list">${v.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`;
}
return v.map((obj,i)=>`<div class="object-card"><h5>条目 ${i+1}</h5>${objectTable(obj)}</div>`).join('');
}
if(typeof v === 'object') return objectTable(v);
const str=String(v);
if(str.length>180) return `<p>${esc(str)}</p>`;
return `<p>${esc(str)}</p>`;
}
function objectTable(obj){ return `<div class="table-wrap"><table><tbody>${Object.entries(obj).filter(([k,v])=>!isEmpty(v)).map(([k,v])=>`<tr><th>${esc(k)}</th><td>${renderValue(v)}</td></tr>`).join('')}</tbody></table></div>`; }
function renderSearch(){
const q=document.getElementById('globalSearch').value.trim().toLowerCase(); const box=document.getElementById('searchResults');
if(!q){box.style.display='none';return;}
const res=[]; SEARCH_KEYS.forEach(k=>(DATA[k]||[]).forEach(it=>{ if(itemText(it).includes(q)) res.push({key:k,item:it}); }));
box.style.display='block'; box.innerHTML = `<div style="padding:10px 12px"><b>全局搜索结果:${res.length} 条</b></div>` + (res.length?res.slice(0,50).map(r=>`<div class="result-row"><div><div class="result-title">${esc(titleOf(r.item))}</div><div class="result-source">来源栏目:${esc(KEY_TO_TITLE[r.key]||r.key)} ${r.item['风险等级']?'|风险:'+esc(r.item['风险等级']):''}</div></div><button class="linkbtn" onclick="openDetail('${r.key}','${esc(r.item.id||titleOf(r.item))}')">查看</button></div>`).join(''):'<div class="empty">无匹配结果</div>');
}
init();
</script>
</body>
</html>
@@ -0,0 +1,220 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>台海经济民生叙事监测专题平台</title>
<style>
:root {
--bg:#7f1d1d;
--bg2:#991b1b;
--panel:#fff7ed;
--card:#ffffff;
--text:#1f2937;
--muted:#6b7280;
--line:#e5e7eb;
--primary:#b91c1c;
--primary2:#ef4444;
--amber:#f59e0b;
--green:#16a34a;
--blue:#2563eb;
--shadow: 0 18px 45px rgba(30, 41, 59, .18);
--radius: 22px;
}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',Arial,sans-serif;background:linear-gradient(135deg,var(--bg),#b91c1c 45%,#f97316);color:var(--text);}
a{color:var(--primary);text-decoration:none}
a:hover{text-decoration:underline}
.shell{max-width:1440px;margin:0 auto;padding:28px 24px 60px;}
.hero{background:linear-gradient(135deg,rgba(255,255,255,.94),rgba(255,247,237,.88));border:1px solid rgba(255,255,255,.8);border-radius:32px;padding:28px;box-shadow:var(--shadow);position:relative;overflow:hidden}
.hero:after{content:'';position:absolute;right:-80px;top:-120px;width:300px;height:300px;border:42px solid rgba(185,28,28,.08);border-radius:50%;}
.hero h1{margin:0;font-size:34px;letter-spacing:.5px;color:#7f1d1d}
.hero p{max-width:980px;line-height:1.75;color:#4b5563;font-size:15px;margin:14px 0 0}
.hero-meta{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px}
.badge{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:7px 12px;font-size:12px;background:#fff;border:1px solid #fecaca;color:#7f1d1d;font-weight:700}
.toolbar{margin:18px 0;background:rgba(255,255,255,.92);border:1px solid rgba(255,255,255,.8);border-radius:22px;padding:14px;display:grid;grid-template-columns:1fr 180px 180px 180px;gap:12px;box-shadow:0 8px 24px rgba(0,0,0,.08)}
.toolbar input,.toolbar select{width:100%;border:1px solid #e5e7eb;border-radius:14px;padding:12px 14px;font-size:14px;background:#fff;color:var(--text);outline:none}
.tabs{position:sticky;top:0;z-index:10;margin:16px 0 18px;background:rgba(255,255,255,.9);backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.8);border-radius:24px;padding:8px;display:flex;gap:8px;overflow-x:auto;box-shadow:0 10px 24px rgba(0,0,0,.12)}
.tab-btn{border:0;background:transparent;white-space:nowrap;padding:11px 15px;border-radius:18px;color:#6b7280;font-weight:800;cursor:pointer}
.tab-btn.active{background:linear-gradient(135deg,#b91c1c,#ef4444);color:white;box-shadow:0 8px 18px rgba(185,28,28,.25)}
.main{background:rgba(255,255,255,.93);border:1px solid rgba(255,255,255,.8);border-radius:30px;padding:24px;box-shadow:var(--shadow);min-height:580px}
.section-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:18px;border-bottom:1px solid var(--line);padding-bottom:14px}
.section-head h2{margin:0;color:#7f1d1d;font-size:24px}
.section-head p{margin:6px 0 0;color:var(--muted);line-height:1.65}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
.grid2{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:18px;box-shadow:0 8px 20px rgba(31,41,55,.06);position:relative;overflow:hidden}
.card h3{margin:0 0 10px;color:#111827;font-size:17px}
.card p{margin:8px 0;color:#4b5563;line-height:1.68;font-size:14px}
.kpi .value{font-size:28px;font-weight:900;color:#991b1b;margin-top:8px}
.kpi .sub{font-size:13px;color:#6b7280;margin-top:6px}
.tag-row{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}
.tag{display:inline-flex;border-radius:999px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;padding:4px 8px;font-size:12px;font-weight:700}
.tag.high,.risk-high{background:#fee2e2;border-color:#fecaca;color:#991b1b}
.tag.medium,.risk-medium{background:#fef3c7;border-color:#fde68a;color:#92400e}
.tag.low,.risk-low{background:#dcfce7;border-color:#bbf7d0;color:#166534}
.temp-wrap{display:grid;grid-template-columns: 1fr 1fr;gap:16px;margin-bottom:18px}
.temp-card{background:linear-gradient(135deg,#fff,#fff7ed);border:1px solid #fed7aa;border-radius:24px;padding:18px}
.bars{display:flex;gap:8px;margin-top:12px;align-items:end;height:74px}
.bar{flex:1;background:linear-gradient(180deg,#ef4444,#991b1b);border-radius:10px 10px 4px 4px;min-height:8px;position:relative}
.bar span{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);font-size:12px;font-weight:900;color:#7f1d1d;margin-bottom:4px}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:18px;background:#fff}
table{border-collapse:collapse;width:100%;font-size:14px;min-width:780px}
th,td{border-bottom:1px solid var(--line);padding:12px 14px;text-align:left;vertical-align:top;line-height:1.55}
th{background:#fff7ed;color:#7f1d1d;font-weight:900;position:sticky;top:0}
tr:hover td{background:#fffafa}
.timeline{position:relative;margin-left:8px}
.timeline:before{content:'';position:absolute;left:18px;top:4px;bottom:4px;width:3px;background:#fecaca;border-radius:99px}
.tl-item{position:relative;margin:0 0 14px 0;padding-left:54px}
.tl-dot{position:absolute;left:8px;top:18px;width:23px;height:23px;background:#fff;border:5px solid #b91c1c;border-radius:50%;box-shadow:0 0 0 5px #fee2e2}
.tl-card{cursor:pointer;transition:.2s}
.tl-card:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(185,28,28,.16)}
.tl-date{font-weight:900;color:#991b1b;font-size:13px;margin-bottom:6px}
.pill{display:inline-block;padding:5px 9px;border-radius:999px;background:#f3f4f6;color:#4b5563;font-size:12px;font-weight:800;margin-right:6px;margin-top:6px}
.source-list{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
.source-item{border:1px solid var(--line);border-radius:16px;padding:14px;background:#fff}
.source-item strong{color:#7f1d1d}
.muted{color:var(--muted)}
.empty{padding:40px;border:1px dashed #fecaca;border-radius:20px;text-align:center;color:#9ca3af;background:#fffafa}
.modal-backdrop{position:fixed;inset:0;background:rgba(17,24,39,.55);display:none;align-items:center;justify-content:center;z-index:50;padding:20px}
.modal{max-width:900px;width:100%;max-height:86vh;overflow:auto;background:#fff;border-radius:28px;box-shadow:0 32px 80px rgba(0,0,0,.35);padding:24px;border:1px solid #fee2e2}
.modal h2{color:#7f1d1d;margin:0 0 10px}
.modal-close{float:right;border:0;background:#fee2e2;color:#991b1b;border-radius:12px;padding:8px 12px;font-weight:900;cursor:pointer}
.footer{margin-top:18px;color:#fff7ed;text-align:center;font-size:13px;line-height:1.7}
@media (max-width:1100px){.grid{grid-template-columns:repeat(2,1fr)}.grid3{grid-template-columns:repeat(2,1fr)}.toolbar{grid-template-columns:1fr 1fr}.source-list{grid-template-columns:1fr}}
@media (max-width:720px){.shell{padding:16px 12px 42px}.hero h1{font-size:25px}.main{padding:16px}.grid,.grid2,.grid3,.temp-wrap{grid-template-columns:1fr}.toolbar{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="shell">
<header class="hero">
<h1>台海经济民生叙事监测专题平台</h1>
<p>围绕“军事介入台海经济民生必下降”这一叙事,建立公开资料研究型数据库。页面用于拆解事件、预算、军售、半导体、航运、贸易、受众和平台传播之间的关系,同时展示竞争叙事与反弹逻辑。</p>
<div class="hero-meta">
<span class="badge">版本 1.0</span>
<span class="badge">生成日期:2026-06-25</span>
<span class="badge">公开资料研究</span>
<span class="badge">非操纵性传播方案</span>
</div>
</header>
<div class="toolbar">
<input id="searchBox" placeholder="搜索事件、叙事、受众、关键词……" />
<select id="categoryFilter"><option value="all">全部类别</option></select>
<select id="riskFilter"><option value="all">全部风险</option><option value="high">高风险</option><option value="medium">中风险</option><option value="low">低风险</option></select>
<select id="regionFilter"><option value="all">全部地区</option></select>
</div>
<nav class="tabs" id="tabs"></nav>
<main class="main" id="content"></main>
<div class="footer">边界说明:本页面仅用于公开资料研究、政策风险监测与舆论观察;不提供虚假信息、隐蔽影响、假账号或定向操纵建议。</div>
</div>
<div class="modal-backdrop" id="modalBackdrop"><div class="modal" id="modal"></div></div>
<script id="dataset" type="application/json">{"metadata": {"title": "台海经济民生叙事监测专题数据库", "version": "1.0", "generated_at": "2026-06-25", "language": "zh-CN", "scope": "公开资料研究、舆论观察、风险评估、政策过程分析。", "safety_boundary": "不包含虚假信息制造、假账号操作、定向骚扰、隐蔽影响或操纵性传播方案。", "core_question": "美国对台军售、印太军事部署与台海战备如何被叙事化为经济民生风险,以及该叙事如何与威慑避战、供应链韧性等反叙事竞争。"}, "tabs": [{"id": "overview", "name": "总览仪表盘", "description": "展示当前叙事温度、核心 KPI、重大事件和风险等级。"}, {"id": "events", "name": "关键事件时间线", "description": "汇总军售、军演、预算、半导体、贸易和航运事件。"}, {"id": "budget", "name": "防务预算与民生成本", "description": "分析国防预算、特别预算、财政压力和民生争议。"}, {"id": "arms", "name": "美国军售与军工链", "description": "展示军售项目、金额、后勤、供应商核查和交付争议。"}, {"id": "semi", "name": "半导体与硅盾变化", "description": "展示台积电、ICT 出口、半导体外移和供应链韧性。"}, {"id": "maritime", "name": "海峡航运与贸易通道", "description": "展示封锁、检疫、海警、保险和海峡贸易风险。"}, {"id": "trade", "name": "美台贸易与美国成本", "description": "分析美台贸易逆差、关税、产业回流和纳税人成本叙事。"}, {"id": "tw_audience", "name": "台湾内部受众", "description": "分析 DPP、KMT、中间选民、青年、工商业界和疑美群体。"}, {"id": "us_audience", "name": "美国国内受众", "description": "分析鹰派、MAGA 成本派、财政保守派和科技安全派。"}, {"id": "allies", "name": "亚太盟友与产业界", "description": "分析日本、韩国、菲律宾、澳大利亚、航运业和投资者。"}, {"id": "platforms", "name": "平台传播与关键词", "description": "展示平台观察矩阵和中英文关键词库。"}, {"id": "frames", "name": "叙事框架与反叙事", "description": "展示核心叙事框架、脆弱点和反叙事。"}], "sources": [{"id": "SRC-TW-MND", "name": "台湾国防部 National Defense Budgeting", "type": "官方", "url": "https://www.mnd.gov.tw/en/publication/129", "credibility": "A", "notes": "2026 防务预算、NATO 口径、GDP 占比、国防部预算增幅。"}, {"id": "SRC-TSMC", "name": "TSMC 官方新闻稿:扩大美国投资", "type": "企业官方", "url": "https://pr.tsmc.com/english/news/3210", "credibility": "A", "notes": "台积电美国投资增至 1650 亿美元、三座晶圆厂、两座先进封装设施、研发中心。"}, {"id": "SRC-USTR", "name": "美国贸易代表办公室:Taiwan", "type": "官方", "url": "https://ustr.gov/countries-regions/china/taiwan", "credibility": "A", "notes": "2025 年美台货物贸易额、美国对台出口、进口、逆差。"}, {"id": "SRC-TW-MOF-2025", "name": "台湾财政部 / Taiwan Today:2025 外贸创新高", "type": "官方/官方媒体", "url": "https://www.taiwantoday.tw/print/Economics/Top-News/280295/Taiwan-reports-record-high-foreign-trade-in-2025", "credibility": "A-", "notes": "2025 年台湾出口、进口、贸易顺差数据。"}, {"id": "SRC-CSIS-STRAIT", "name": "CSIS:Taiwan Strait trade disruption analysis", "type": "智库", "url": "https://www.csis.org/analysis/disruptions-trade-taiwan-strait-would-severely-impact-chinas-economy", "credibility": "A-", "notes": "台湾海峡贸易规模、区域经济体依赖估算。"}, {"id": "SRC-CHICAGO-2025", "name": "Chicago Council:Americans Grow More Supportive of Aiding Taiwan", "type": "民调/研究机构", "url": "https://globalaffairs.org/research/public-opinion-survey/americans-grow-more-supportive-aiding-taiwan-china-crisis", "credibility": "A-", "notes": "美国公众在台湾危机中的援助、制裁、军援、海军打破封锁、派兵支持率。"}, {"id": "SRC-BROOKINGS-2026", "name": "Brookings:How partisan is Taiwan's security debate?", "type": "智库/民调分析", "url": "https://www.brookings.edu/articles/how-partisan-is-taiwans-security-debate/", "credibility": "A-", "notes": "台湾特别防务预算支持率及党派差异。"}, {"id": "SRC-DSCA-TECO", "name": "DSCA:TECRO major arms sales archive", "type": "美国官方", "url": "https://www.dsca.mil/Press-Media/Major-Arms-Sales/Tag/295270/taipei-economic-and-cultural-representative-office-in-the-united-states-tecro", "credibility": "A", "notes": "2025 年 12 月及 2025 年 11 月对台军售通知。"}, {"id": "SRC-USTBC-2025-ARMS", "name": "USTBC:Eight arms sales to Taiwan valued at over US$11.1bn", "type": "行业组织/汇总", "url": "https://www.us-taiwan.org/resources/ustbc-comments-on-eight-arms-sales-to-taiwan-valued-at-over-us-11-1-billion/", "credibility": "A-", "Line truncated
<script>
const DATA = JSON.parse(document.getElementById('dataset').textContent);
let activeTab = 'overview';
const $ = sel => document.querySelector(sel);
const content = $('#content');
function sourceLinks(ids=[]){
return ids.map(id=>DATA.sources.find(s=>s.id===id)).filter(Boolean)
.map(s=>`<a href="${s.url}" target="_blank">${s.name}</a>`).join(';') || '<span class="muted">未标注来源</span>';
}
function riskClass(r){ return r==='high'||r==='高'?'high':r==='medium'||r==='中'?'medium':'low'; }
function esc(v){return String(v??'').replace(/[&<>"]/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]));}
function matchFilter(obj){
const q = $('#searchBox').value.trim().toLowerCase();
const cat = $('#categoryFilter').value;
const risk = $('#riskFilter').value;
const region = $('#regionFilter').value;
const text = JSON.stringify(obj).toLowerCase();
if(q && !text.includes(q)) return false;
if(cat !== 'all' && (obj.category||obj.type) !== cat) return false;
if(risk !== 'all' && riskClass(obj.risk_level||obj.risk) !== risk) return false;
if(region !== 'all' && (obj.region||'') !== region) return false;
return true;
}
function initFilters(){
const cats = [...new Set([...DATA.events.map(e=>e.category), ...DATA.indicators.map(i=>i.category)])].filter(Boolean);
$('#categoryFilter').innerHTML = '<option value="all">全部类别</option>' + cats.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('');
const regions = [...new Set([...DATA.events.map(e=>e.region), ...DATA.audiences.map(a=>a.region)])].filter(Boolean);
$('#regionFilter').innerHTML = '<option value="all">全部地区</option>' + regions.map(r=>`<option value="${esc(r)}">${esc(r)}</option>`).join('');
}
function initTabs(){
$('#tabs').innerHTML = DATA.tabs.map(t=>`<button class="tab-btn ${t.id===activeTab?'active':''}" data-tab="${t.id}">${t.name}</button>`).join('');
document.querySelectorAll('.tab-btn').forEach(btn=>btn.onclick=()=>{activeTab=btn.dataset.tab; render(); initTabs();});
}
function head(title, desc){ return `<div class="section-head"><div><h2>${title}</h2><p>${desc||''}</p></div></div>`; }
function kpiCard(k){ return `<div class="card kpi"><h3>${k.name}</h3><div class="value">${k.value}</div><div class="sub">${k.sub_value||''}</div><p>${k.interpretation||''}</p><div class="tag-row"><span class="tag ${riskClass(k.risk_level)}">${k.risk_level}风险</span><span class="tag">${k.category}</span></div><p class="muted">来源:${sourceLinks(k.source_ids)}</p></div>`; }
function card(title, body, tags=[], sources=[]){ return `<div class="card"><h3>${title}</h3><p>${body}</p><div class="tag-row">${tags.map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>${sources.length?`<p class="muted">来源:${sourceLinks(sources)}</p>`:''}</div>`; }
function renderOverview(){
const temp = DATA.scoring_rules.current_temperature_sample;
content.innerHTML = head('总览仪表盘', DATA.tabs.find(t=>t.id==='overview').description) +
`<div class="temp-wrap"><div class="temp-card"><h3>叙事温度样本</h3><p>${temp.explanation}</p><div class="bars">${
[['原叙事',temp.original_narrative_heat],['竞争',temp.competing_narrative_heat],['反弹',temp.backlash_intensity],['政策',temp.policy_impact_likelihood]].map(([n,v])=>`<div class="bar" style="height:${v*14}px"><span>${v}</span></div>`).join('')
}</div><div class="tag-row">${temp.key_drivers.map(x=>`<span class="tag">${x}</span>`).join('')}</div></div><div class="temp-card"><h3>专题解释边界</h3><p>${DATA.metadata.safety_boundary}</p><p>核心问题:${DATA.metadata.core_question}</p></div></div>`+
`<div class="grid">${DATA.dashboard_kpis.map(kpiCard).join('')}</div>`+
`<div style="margin-top:18px">${head('数据缺口与后续采集', '这些字段已在页面预留,但需要后续日度或周度采样。')}<div class="grid3">${DATA.data_gaps.map(g=>card(g.item, g.reason, [g.status])).join('')}</div></div>`;
}
function openEvent(e){
$('#modal').innerHTML = `<button class="modal-close" onclick="closeModal()">关闭</button><h2>${e.title}</h2><p><strong>时间:</strong>${e.date} <strong>类别:</strong>${e.category} <strong>风险:</strong>${e.risk_level}</p><p>${e.event_summary}</p><h3>经济传导</h3><p>${(e.economic_link||[]).join('、')}</p><h3>叙事影响</h3><p>${e.narrative_effect}</p><h3>涉及行为体</h3><p>${(e.involved_actors||[]).join('、')}</p><h3>受影响受众</h3><p>${(e.affected_audiences||[]).join('、')}</p><div class="tag-row">${(e.tags||[]).map(t=>`<span class="tag">${t}</span>`).join('')}</div><p class="muted">来源:${sourceLinks(e.source_ids)}</p>`;
$('#modalBackdrop').style.display='flex';
}
function closeModal(){$('#modalBackdrop').style.display='none'}
window.closeModal = closeModal;
$('#modalBackdrop').onclick = e => { if(e.target.id==='modalBackdrop') closeModal(); };
function renderEvents(){
const arr = DATA.events.filter(matchFilter).sort((a,b)=>a.date.localeCompare(b.date));
content.innerHTML = head('关键事件时间线', DATA.tabs.find(t=>t.id==='events').description) + (arr.length?`<div class="timeline">${arr.map(e=>`<div class="tl-item"><div class="tl-dot"></div><div class="card tl-card" onclick='openEvent(${JSON.stringify(e).replace(/'/g,"&#39;")})'><div class="tl-date">${e.date}</div><h3>${e.title}</h3><p>${e.event_summary}</p><span class="pill">${e.category}</span><span class="pill">${e.region}</span><span class="pill">${e.risk_level}</span></div></div>`).join('')}</div>`:'<div class="empty">没有匹配事件</div>');
}
function renderBudget(){
content.innerHTML = head('防务预算与民生成本', DATA.tabs.find(t=>t.id==='budget').description) + `<div class="grid">${DATA.budget.cards.map(c=>`<div class="card kpi"><h3>${c.label}</h3><div class="value">${c.value}</div><p>${c.note}</p><span class="tag ${riskClass(c.risk)}">${c.risk}风险</span></div>`).join('')}</div><div style="margin-top:18px" class="grid2">${DATA.budget.debate_points.map(d=>card(d.title,d.content,d.tags)).join('')}</div><p class="muted">来源:${sourceLinks(DATA.budget.source_ids)}</p>`;
}
function renderArms(){
const rows = DATA.arms_sales.map(a=>`<tr><td><strong>${a.project}</strong><br><span class="muted">${a.id}</span></td><td>${a.amount_usd}</td><td>${a.type}</td><td>${a.narrative}</td><td>${sourceLinks(a.source_ids)}</td></tr>`).join('');
content.innerHTML = head('美国军售与军工链', DATA.tabs.find(t=>t.id==='arms').description) + `<div class="table-wrap"><table><thead><tr><th>项目</th><th>估值</th><th>类型</th><th>叙事关联</th><th>来源</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}
function renderSemi(){
content.innerHTML = head('半导体与“硅盾”变化', DATA.tabs.find(t=>t.id==='semi').description) + `<div class="grid">${DATA.semiconductor.tsmc_investment.map(x=>`<div class="card kpi"><h3>${x.label}</h3><div class="value">${x.value}</div></div>`).join('')}</div><div style="margin-top:18px" class="table-wrap"><table><thead><tr><th>出口类别</th><th>金额</th><th>同比</th><th>叙事意义</th></tr></thead><tbody>${DATA.semiconductor.export_structure.map(x=>`<tr><td>${x.category}</td><td>${x.amount}</td><td>${x.yoy}</td><td>${x.meaning}</td></tr>`).join('')}</tbody></table></div><div style="margin-top:18px" class="grid2">${DATA.semiconductor.frames.map(f=>card(f.title,f.content)).join('')}</div><p class="muted">来源:${sourceLinks(DATA.semiconductor.source_ids)}</p>`;
}
function renderMaritime(){
content.innerHTML = head('台湾海峡航运与贸易通道', DATA.tabs.find(t=>t.id==='maritime').description) + `<div class="grid">${DATA.maritime.metrics.map(m=>`<div class="card kpi"><h3>${m.label}</h3><div class="value">${m.value}</div><p>${m.note}</p></div>`).join('')}</div><div style="margin-top:18px" class="grid2">${DATA.maritime.risk_types.map(r=>card(r.type,r.transmission)).join('')}</div><p class="muted">来源:${sourceLinks(DATA.maritime.source_ids)}</p>`;
}
function renderTrade(){
content.innerHTML = head('美台贸易与美国成本叙事', DATA.tabs.find(t=>t.id==='trade').description) + `<div class="grid">${DATA.trade.us_taiwan.map(x=>`<div class="card kpi"><h3>${x.label}</h3><div class="value">${x.value}</div><p>${x.note||''}</p></div>`).join('')}</div><div style="margin-top:18px" class="grid2">${DATA.trade.frames.map(f=>card(f.title,f.content)).join('')}</div><p class="muted">来源:${sourceLinks(DATA.trade.source_ids)}</p>`;
}
function audienceCard(a){
return `<div class="card"><h3>${a.audience_group}</h3><p><strong>反应:</strong>${a.current_reaction} <strong>类别:</strong>${a.category}</p><p><strong>关注点:</strong>${a.concerns.join('、')}</p><p><strong>可能行为:</strong>${a.likely_behavior.join('、')}</p><p><strong>竞争叙事:</strong>${a.competing_narratives.join('、')}</p><div class="tag-row"><span class="tag">${a.region}</span><span class="tag">置信度:${a.confidence}</span></div><p class="muted">${a.caveat}</p></div>`;
}
function renderAudience(regionSet, title, desc){
const arr = DATA.audiences.filter(a=>regionSet.includes(a.region)).filter(matchFilter);
content.innerHTML = head(title, desc) + (arr.length?`<div class="grid3">${arr.map(audienceCard).join('')}</div>`:'<div class="empty">没有匹配受众</div>');
}
function renderPlatforms(){
const platCards = DATA.platform_observations.map(p=>`<div class="card"><h3>${p.platform}</h3><p>${p.narrative_variant}</p><p><strong>受众反应:</strong>${p.audience_reaction}</p><div class="tag-row">${p.example_keywords.map(k=>`<span class="tag">${k}</span>`).join('')}<span class="tag ${p.backlash_level==='high'?'high':p.backlash_level==='medium'?'medium':'low'}">反弹:${p.backlash_level}</span></div><p class="muted">${p.caveat}</p></div>`).join('');
const kw = Object.entries(DATA.keywords).map(([lang,groups])=>`<div class="card"><h3>${lang}</h3>${groups.map(g=>`<p><strong>${g.category}:</strong>${g.terms.map(t=>`<span class="pill">${t}</span>`).join('')}</p>`).join('')}</div>`).join('');
content.innerHTML = head('平台传播与关键词监测', DATA.tabs.find(t=>t.id==='platforms').description) + `<div class="grid2">${platCards}</div><div style="margin-top:18px" class="grid2">${kw}</div>`;
}
function renderFrames(){
const arr = DATA.narrative_frames.filter(matchFilter);
content.innerHTML = head('叙事框架与反叙事库', DATA.tabs.find(t=>t.id==='frames').description) + `<div class="grid2">${arr.map(f=>`<div class="card"><h3>${f.frame_name}</h3><p><strong>核心主张:</strong>${f.core_claim}</p><p><strong>支撑证据:</strong>${f.supporting_evidence.join(';')}</p><p><strong>脆弱点:</strong>${f.vulnerability}</p><p><strong>反叙事:</strong>${f.counter_frame}</p><div class="tag-row">${f.typical_trigger.map(t=>`<span class="tag">${t}</span>`).join('')}<span class="tag ${riskClass(f.risk_level)}">${f.risk_level}</span></div><p class="muted">来源:${sourceLinks(f.source_ids)}</p></div>`).join('')}</div><div style="margin-top:18px">${head('来源库', '核心事实来源与可信度。')}<div class="source-list">${DATA.sources.map(s=>`<div class="source-item"><strong>${s.name}</strong><p>${s.notes}</p><p><span class="pill">${s.type}</span><span class="pill">可信度 ${s.credibility}</span></p><a href="${s.url}" target="_blank">打开来源</a></div>`).join('')}</div></div>`;
}
function render(){
const t = DATA.tabs.find(x=>x.id===activeTab);
if(activeTab==='overview') return renderOverview();
if(activeTab==='events') return renderEvents();
if(activeTab==='budget') return renderBudget();
if(activeTab==='arms') return renderArms();
if(activeTab==='semi') return renderSemi();
if(activeTab==='maritime') return renderMaritime();
if(activeTab==='trade') return renderTrade();
if(activeTab==='tw_audience') return renderAudience(['Taiwan'], '台湾内部受众反应', t.description);
if(activeTab==='us_audience') return renderAudience(['United States'], '美国国内受众反应', t.description);
if(activeTab==='allies') return renderAudience(['Japan','South Korea','Philippines','Australia','Global'], '亚太盟友与产业界反应', t.description);
if(activeTab==='platforms') return renderPlatforms();
if(activeTab==='frames') return renderFrames();
}
['searchBox','categoryFilter','riskFilter','regionFilter'].forEach(id=>document.getElementById(id).addEventListener('input', render));
initFilters(); initTabs(); render();
</script>
</body>
</html>
@@ -0,0 +1,302 @@
<!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: #f6f2ee;
--panel: #fffaf6;
--panel2: #ffffff;
--ink: #201815;
--muted: #6f625c;
--line: #dbcfc8;
--line2: #bdaaa0;
--accent: #8f1f1b;
--accent2: #b63a2f;
--soft: #f1e2dc;
--soft2: #ead6cf;
--warn: #8c5a13;
--shadow: 0 16px 42px rgba(74, 37, 29, 0.12);
--radius: 18px;
}
* { box-sizing: border-box; }
html { scroll-behavior:smooth; }
body {
margin:0; font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", Arial, sans-serif;
color:var(--ink); background:
radial-gradient(circle at 12% -8%, rgba(143,31,27,.20), transparent 28%),
radial-gradient(circle at 95% 2%, rgba(182,58,47,.11), transparent 24%),
linear-gradient(180deg, #fff8f3 0%, var(--bg) 42%, #f4ece8 100%);
}
header.hero {
padding: 34px 34px 18px;
border-bottom: 1px solid var(--line);
}
.hero-inner { max-width: 1500px; margin:0 auto; }
.eyebrow {
display:inline-flex; gap:8px; align-items:center; padding:7px 12px; border:1px solid var(--line2);
border-radius:999px; background:rgba(255,255,255,.72); color:var(--accent); font-weight:700; font-size:13px;
}
h1 { margin:14px 0 10px; font-size:34px; letter-spacing:.5px; line-height:1.16; }
.subtitle { max-width:1050px; color:var(--muted); line-height:1.75; font-size:15px; }
.boundary {
margin-top:14px; padding:14px 16px; border-left:5px solid var(--accent); background:rgba(255,255,255,.72);
border-radius:14px; color:#4a332d; line-height:1.7;
}
.top-meta { display:flex; flex-wrap:wrap; gap:10px; margin-top:18px; }
.chip {
display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); background:var(--panel2);
color:#4b3933; border-radius:999px; padding:7px 11px; font-size:12px; font-weight:650;
}
.toolbar-wrap { position:sticky; top:0; z-index:20; background:rgba(246,242,238,.92); backdrop-filter: blur(10px); border-bottom:1px solid var(--line); }
.toolbar { max-width:1500px; margin:0 auto; padding:12px 34px; display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.tabs { display:flex; gap:8px; overflow:auto; flex:1; padding-bottom:2px; }
.tab-btn {
border:1px solid var(--line2); background:var(--panel); color:#3a2a25; border-radius:999px; padding:10px 15px;
cursor:pointer; white-space:nowrap; font-weight:750; font-size:13px; transition:.18s ease;
}
.tab-btn.active, .tab-btn:hover { background:var(--accent); color:white; border-color:var(--accent); }
.searchbar { display:flex; gap:8px; align-items:center; min-width:290px; }
.searchbar input, select {
width:100%; border:1px solid var(--line2); border-radius:12px; padding:10px 12px; background:#fff; color:var(--ink);
}
main { max-width:1500px; margin:0 auto; padding:24px 34px 54px; }
.tab-pane { display:none; animation: fadeIn .18s ease; }
.tab-pane.active { display:block; }
@keyframes fadeIn { from {opacity:.35; transform:translateY(4px)} to {opacity:1; transform:none} }
.section-title { display:flex; align-items:flex-end; justify-content:space-between; gap:18px; margin:0 0 15px; }
.section-title h2 { margin:0; font-size:24px; }
.section-title p { margin:6px 0 0; color:var(--muted); line-height:1.7; }
.grid { display:grid; gap:16px; }
.grid.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.card {
background:rgba(255,255,255,.86); border:1px solid var(--line); border-radius:var(--radius); padding:18px; box-shadow: var(--shadow);
}
.card.plain { box-shadow:none; background:var(--panel); }
.card h3 { margin:0 0 10px; font-size:18px; }
.card h4 { margin:0 0 8px; font-size:15px; }
.card p { color:#4e3e38; line-height:1.75; margin:8px 0; }
.kpi { min-height:120px; position:relative; overflow:hidden; }
.kpi::after { content:""; position:absolute; width:110px; height:110px; right:-40px; top:-45px; border-radius:50%; background:rgba(143,31,27,.08); }
.kpi .num { font-size:32px; font-weight:900; color:var(--accent); margin-top:8px; }
.kpi .label { color:var(--muted); font-size:13px; line-height:1.5; }
.tagrow { display:flex; flex-wrap:wrap; gap:7px; margin-top:10px; }
.tag { display:inline-flex; padding:5px 9px; border-radius:999px; background:var(--soft); border:1px solid #e3cbc2; color:#5a2b25; font-size:12px; font-weight:650; }
.tag.risk5 { background:#8f1f1b; color:#fff; border-color:#8f1f1b; }
.tag.risk4 { background:#c46845; color:#fff; border-color:#c46845; }
.tag.risk3 { background:#e5bd76; color:#3c2a14; border-color:#e5bd76; }
.tag.risk2 { background:#ede0bd; color:#3c2a14; }
.tag.risk1 { background:#e5efe1; color:#21401d; }
.filters { display:flex; gap:10px; flex-wrap:wrap; margin:0 0 16px; }
.filters select, .filters input { width:auto; min-width:190px; }
.timeline { position:relative; margin-left:8px; }
.timeline::before { content:""; position:absolute; left:15px; top:0; bottom:0; width:2px; background:var(--line2); }
.event-card { display:grid; grid-template-columns:42px 1fr; gap:13px; margin-bottom:14px; }
.event-dot { width:32px; height:32px; border-radius:50%; background:var(--accent); color:white; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:900; position:relative; z-index:1; box-shadow:0 6px 18px rgba(143,31,27,.22); }
.event-body { background:#fff; border:1px solid var(--line); border-radius:17px; padding:16px; box-shadow: 0 10px 26px rgba(74, 37, 29, 0.08); }
.event-head { display:flex; gap:10px; justify-content:space-between; align-items:flex-start; }
.event-date { color:var(--accent); font-weight:850; font-size:13px; white-space:nowrap; }
.btn {
border:1px solid var(--accent); background:var(--accent); color:#fff; border-radius:11px; padding:8px 11px; cursor:pointer; font-weight:750;
}
.btn.ghost { background:#fff; color:var(--accent); }
.detail-grid { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:12px; margin-top:12px; }
.detail-box { border:1px solid var(--line); border-radius:14px; padding:12px; background:#fffaf7; }
.detail-box b { color:var(--accent); display:block; margin-bottom:6px; }
ul.clean { margin:8px 0 0 18px; padding:0; }
ul.clean li { margin:5px 0; line-height:1.65; }
.matrix-table, .data-table { width:100%; border-collapse:separate; border-spacing:0; overflow:hidden; border:1px solid var(--line); border-radius:16px; background:#fff; }
.matrix-table th, .matrix-table td, .data-table th, .data-table td { border-bottom:1px solid var(--line); border-right:1px solid var(--line); padding:12px; vertical-align:top; line-height:1.65; }
.matrix-table th, .data-table th { background:#6f1714; color:#fff; text-align:left; }
.matrix-table tr:last-child td, .data-table tr:last-child td { border-bottom:none; }
.matrix-table td:last-child, .matrix-table th:last-child, .data-table td:last-child, .data-table th:last-child { border-right:none; }
.level-cell { font-weight:800; color:var(--accent); white-space:nowrap; }
.narrative-card { min-height: 330px; display:flex; flex-direction:column; }
.narrative-card .grow { flex:1; }
.two-col-list { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.mini-title { font-weight:850; color:var(--accent); margin-bottom:5px; }
.source-level { width:46px; height:46px; border-radius:50%; display:flex; align-items:center; justify-content:center; background:var(--accent); color:#fff; font-weight:900; }
.actor-icon { width:44px; height:44px; border-radius:14px; background:var(--soft); color:var(--accent); display:flex; align-items:center; justify-content:center; font-weight:900; border:1px solid #dfc7bf; }
.actor-row { display:flex; gap:12px; align-items:flex-start; }
.modal-backdrop { position:fixed; inset:0; display:none; align-items:center; justify-content:center; background:rgba(30,18,14,.56); z-index:50; padding:24px; }
.modal-backdrop.active { display:flex; }
.modal { width:min(980px, 96vw); max-height:88vh; overflow:auto; background:#fffdfa; border-radius:22px; border:1px solid var(--line); box-shadow:0 30px 90px rgba(0,0,0,.32); }
.modal-head { position:sticky; top:0; background:#fffdfa; border-bottom:1px solid var(--line); padding:18px 20px; display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
.modal-head h3 { margin:0; font-size:21px; }
.modal-content { padding:20px; }
.close { border:0; background:var(--soft); color:var(--accent); border-radius:999px; padding:8px 12px; font-weight:900; cursor:pointer; }
.empty { padding:28px; border:1px dashed var(--line2); border-radius:18px; background:#fff; color:var(--muted); text-align:center; }
.schema-code { white-space:pre-wrap; word-break:break-word; background:#231b18; color:#fff8ef; border-radius:16px; padding:16px; line-height:1.65; font-size:13px; max-height:460px; overflow:auto; }
.badge-line { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.notice { border:1px solid #e6c7aa; background:#fff7e9; color:#5a371d; border-radius:15px; padding:14px 16px; line-height:1.7; }
footer { max-width:1500px; margin:0 auto; padding:0 34px 40px; color:var(--muted); font-size:12px; line-height:1.8; }
@media (max-width: 980px) {
header.hero, .toolbar, main, footer { padding-left:18px; padding-right:18px; }
.grid.cols-2, .grid.cols-3, .grid.cols-4, .detail-grid, .two-col-list { grid-template-columns:1fr; }
.searchbar { min-width:100%; }
.event-head { flex-direction:column; }
}
</style>
</head>
<body>
<header class="hero">
<div class="hero-inner">
<div class="eyebrow">专题研究平台 · 精简栏目版 v0.3</div>
<h1>台湾涉两岸政治叙事监测专题</h1>
<div class="subtitle">公开资料监测、叙事结构拆解、平台传播观察、群体匿名反应、竞争叙事识别与主体节点观察</div>
<div class="boundary"><b>研究边界:</b>仅用于公开资料研究和舆情态势理解;不生成定向影响、群体操控、个人画像或宣传执行方案。</div>
<div class="top-meta" id="topMeta"></div>
</div>
</header>
<div class="toolbar-wrap">
<div class="toolbar">
<div class="tabs" id="tabs"></div>
<div class="searchbar"><input id="globalSearch" placeholder="全局搜索:事件、叙事、平台、主体、关键词" /></div>
</div>
</div>
<main>
<section class="tab-pane active" id="pane-overview"></section>
<section class="tab-pane" id="pane-events"></section>
<section class="tab-pane" id="pane-narratives"></section>
<section class="tab-pane" id="pane-structures"></section>
<section class="tab-pane" id="pane-platforms"></section>
<section class="tab-pane" id="pane-audiences"></section>
<section class="tab-pane" id="pane-competing"></section>
<section class="tab-pane" id="pane-actors"></section>
</main>
<div class="modal-backdrop" id="modalBackdrop">
<div class="modal">
<div class="modal-head"><div><h3 id="modalTitle"></h3><div id="modalSub" class="subtitle"></div></div><button class="close" onclick="closeModal()">关闭</button></div>
<div class="modal-content" id="modalContent"></div>
</div>
</div>
<footer>
本页面为单文件离线版,已移除来源审查、风险评估与 Schema 栏目。建议正式研究时继续补充每条事件的原始链接、样本截图、平台互动量和跨平台传播链。
</footer>
<script id="DATA" type="application/json">{"metadata":{"title":"台湾涉两岸政治叙事监测专题","version":"v0.3-content-only","generated_at":"2026-06-25 16:10 JST","time_range":"2024-01-13 至 2026-06-25","timezone":"Asia/Tokyo","orientation":"公开资料监测、叙事结构拆解、平台传播观察、群体匿名反应、竞争叙事识别与主体节点观察","safety_boundary":"仅用于公开资料研究和舆情态势理解;不生成定向影响、群体操控、个人画像或宣传执行方案。","recommended_columns":["专题总览","核心叙事库","叙事结构拆解","触发事件时间线","平台传播生态","受众反应观察","竞争叙事分析","反弹机制观察","关键主体与传播节点"],"update_cycle":"建议日报记录事件与样本,周报比较平台与叙事变化,月报归纳趋势与栏目数据缺口。","display_principles":["字段中文化","信息卡片化","长字段弹窗化","研究边界显性化","数据缺口可追踪"]},"dashboard":{"summary_cards":[{"label":"事件样本","value":"16","note":"覆盖选举、演训、国会、国安、国防预算等触发点"},{"label":"核心叙事","value":"10","note":"含疑美、战争风险、绿色威权、维持现状等"},{"label":"观察平台","value":"9","note":"覆盖公开平台、私域转传样本与跨境平台"},{"label":"受众群体","value":"12","note":"以匿名群体层级呈现关切、共鸣与反弹逻辑"},{"label":"主体节点","value":"16","note":"覆盖官方机构、政党、媒体、研究与事实查核组织"}],"current_focus":["赖清德政府两岸与国安表述变化","台海军事演训与灰色地带活动对战争风险叙事的激活","国会改革、罢免、司法案件带来的绿色威权/程序正义叙事对撞","国防预算、民防韧性和兵役议题对青年与风险家庭的影响","事实查核、AI假信息和社交平台再框架化机制"]},"events":[{"id":"E001","date":"2024-01-13","title":"台湾总统与立委选举","type":"选举政治","risk_level":4,"confidence":"高","source_status":"需在正式版回填原始链接","triggered_narratives":["民主韧性","疑美论","战争风险","选举干预"],"platforms":["YouTube","Facebook","LINE","PTT","Dcard","Threads"],"actors":["总统府","民进党","国民党","民众党","事实查核机构","主流媒体"],"summary":"赖清德胜选,民进党保住总统职位,但立法院形成朝小野大格局。选举期间战争风险、外部因素、假信息与选举干预等叙事被持续激活。","observation_points":["总统胜选与国会失去多数同时发生,形成后续国会叙事冲突的制度背景","选举信息战议题强化事实查核与平台治理的重要性","战争风险与疑美论在选前选后均有延续性"],"risk_dimensions":{"content":4,"spread":4,"audience":4,"political":5},"risk_label":"高风险","research_value":["可作为叙事触发点观察不同平台的框架转换","可用于比对官方定性、媒体再框架和社群反应之间的差异","可继续补充原始链接、热度曲线、事实查核与跨平台传播链"],"data_to_add":["一手公开来源链接","主流媒体报道链接","平台样本截图或标题","热度指标","事实查核记录","后续政策或舆论影响"],"risk_dimension_text":["content:4/5","spread:4/5","audience:4/5","political:5/5"]},{"id":"E002","date":"2024-01","title":"选举期间 AI 与假信息争议","type":"信息战/假信息","risk_level":4,"confidence":"中","source_status":"需回填查核报告、研究报告与平台样本链接","triggered_narratives":["AI假信息","认知战","选举干预","境外信息操纵"],"platforms":["LINE","YouTube","TikTok/Reels/Shorts","Facebook","PTT","X"],"actors":["事实查核机构","研究机构","社交平台","媒体"],"summary":"围绕2024年台湾选举,AI生成内容、深伪、断章取义视频、图卡和私域转传内容成为重要监测对象。","observation_points":["应区分真实政治表达、本地党派攻防和外部信息操纵","LINE私域样本需要依赖查核机构和用户提交样本","短影音平台更容易出现低上下文、强情绪内容"],"risk_dimensions":{"content":5,"spread":4,"audience":4,"operation":5},"risk_label":"高风险","research_value":["可作为叙事触发点观察不同平台的框架转换","可用于比对官方定性、媒体再框架和社群反应之间的差异","可继续补充原始链接、热度曲线、事实查核与跨平台传播链"],"data_to_add":["一手公开来源链接","主流媒体报道链接","平台样本截图或标题","热度指标","事实查核记录","后续政策或舆论影响"],"risk_dimension_text":["content:5/5","spread:4/5","audience:4/5","operation:5/5"]},{"id":"E003","date":"2024-05-20","title":"赖清德就职演说","type":"两岸政治","risk_level":5,"confidence":"高","source_status":"需回填总统府原文、大陆回应与媒体报道链接","triggLine truncated
<script>
const db = JSON.parse(document.getElementById('DATA').textContent);
const panes = [
['overview','总览仪表盘'], ['events','事件时间线'], ['narratives','核心叙事库'], ['structures','叙事结构拆解'],
['platforms','平台传播生态'], ['audiences','受众反应观察'], ['competing','竞争叙事'], ['actors','主体与传播节点']
];
let state = { search:'', eventRisk:'all', eventType:'all' };
const $ = id => document.getElementById(id);
const esc = s => String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const list = arr => Array.isArray(arr) && arr.length ? '<ul class="clean">'+arr.map(x=>'<li>'+esc(x)+'</li>').join('')+'</ul>' : '<span class="muted">暂无</span>';
const tags = (arr, cls='') => Array.isArray(arr) && arr.length ? '<div class="tagrow">'+arr.map(x=>'<span class="tag '+cls+'">'+esc(x)+'</span>').join('')+'</div>' : '';
const riskTag = lv => '<span class="tag risk'+esc(lv)+'">风险 '+esc(lv)+' / 5</span>';
function contains(obj, q) { return !q || JSON.stringify(obj).toLowerCase().includes(q.toLowerCase()); }
function sectionTitle(title, desc) { return `<div class="section-title"><div><h2>${esc(title)}</h2><p>${esc(desc)}</p></div></div>`; }
function card(title, body, extra='') { return `<div class="card"><h3>${esc(title)}</h3>${body}${extra}</div>`; }
function initTabs() {
$('tabs').innerHTML = panes.map(([id,name]) => `<button class="tab-btn ${id==='overview'?'active':''}" data-tab="${id}">${name}</button>`).join('');
document.querySelectorAll('.tab-btn').forEach(b => b.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
document.querySelectorAll('.tab-pane').forEach(x=>x.classList.remove('active'));
$('pane-'+b.dataset.tab).classList.add('active');
}));
}
function initMeta() {
$('topMeta').innerHTML = [
['时间范围', db.metadata.time_range], ['时区', db.metadata.timezone || 'Asia/Tokyo'], ['版本', db.metadata.version], ['生成时间', db.metadata.generated_at]
].map(([k,v])=>`<span class="chip"><b>${esc(k)}:</b>${esc(v)}</span>`).join('');
}
function renderOverview() {
const counts = db.module_counts || [];
const kpis = counts.map(m => `<div class="card kpi"><div class="label">${esc(m.module)}</div><div class="num">${esc(m.count)}</div><div class="label">${esc(m.status)}</div></div>`).join('');
const focus = (db.dashboard?.current_focus || []).map(f => `<div class="card plain"><h4>${esc(f.title || f.name || '重点')}</h4><p>${esc(f.desc || f.description || f.summary || f)}</p></div>`).join('');
const map = (db.research_map || []).map(m => `<div class="card plain"><h3>${esc(m.name)}</h3><p>${esc(m.purpose)}</p>${tags(m.core_fields)}</div>`).join('');
$('pane-overview').innerHTML = sectionTitle('总览仪表盘','集中展示专题边界、数据模块、栏目结构和当前可继续补充的数据方向。') +
`<div class="grid cols-4">${kpis}</div>`+
`<div style="height:18px"></div><div class="notice"><b>更新机制:</b>${esc(db.metadata.update_cycle)}<br><b>页面设计:</b>${esc((db.metadata.display_principles||[]).join('、'))}</div>`+
`<div style="height:18px"></div>`+
sectionTitle('专题栏目结构','每个栏目都对应一个可维护的数据模块,避免页面只展示标题而缺少内容。')+
`<div class="grid cols-4">${map}</div>`+
(focus ? `<div style="height:18px"></div>${sectionTitle('当前重点','从专题数据中抽取的近期观察方向。')}<div class="grid cols-3">${focus}</div>` : '');
}
function renderEvents() {
const types = [...new Set(db.events.map(e=>e.type))];
let events = db.events.filter(e => contains(e, state.search));
if (state.eventRisk !== 'all') events = events.filter(e => String(e.risk_level) === state.eventRisk);
if (state.eventType !== 'all') events = events.filter(e => e.type === state.eventType);
events.sort((a,b)=>String(b.date).localeCompare(String(a.date)));
const controls = `<div class="filters"><select id="eventRisk"><option value="all">全部风险等级</option>${[5,4,3,2,1].map(x=>`<option value="${x}" ${state.eventRisk==String(x)?'selected':''}>风险 ${x} / 5</option>`).join('')}</select><select id="eventType"><option value="all">全部事件类型</option>${types.map(t=>`<option value="${esc(t)}" ${state.eventType===t?'selected':''}>${esc(t)}</option>`).join('')}</select></div>`;
const rows = events.length ? `<div class="timeline">${events.map(e=>`<div class="event-card"><div class="event-dot">${esc(e.risk_level)}</div><div class="event-body"><div class="event-head"><div><div class="event-date">${esc(e.date)} · ${esc(e.type)}</div><h3>${esc(e.title)}</h3></div><div>${riskTag(e.risk_level)}</div></div><p>${esc(e.summary)}</p>${tags(e.triggered_narratives)}<div class="detail-grid"><div class="detail-box"><b>观察要点</b>${list(e.observation_points)}</div><div class="detail-box"><b>风险维度</b>${tags(e.risk_dimension_text)}</div></div><div class="tagrow"><button class="btn ghost" onclick="openEvent('${esc(e.id)}')">查看完整事件卡</button></div></div></div>`).join('')}</div>` : '<div class="empty">没有匹配事件。</div>';
$('pane-events').innerHTML = sectionTitle('事件时间线','按时间顺序展示叙事触发事件,每张事件卡都保留摘要、观察要点、触发叙事、平台和风险维度。') + controls + rows;
$('eventRisk').onchange = e => { state.eventRisk=e.target.value; renderEvents(); };
$('eventType').onchange = e => { state.eventType=e.target.value; renderEvents(); };
}
function renderNarratives() {
const items = db.narratives.filter(n=>contains(n,state.search));
const html = items.length ? `<div class="grid cols-3">${items.map(n=>`<div class="card narrative-card"><div class="badge-line"><h3 style="margin-right:auto">${esc(n.name)}</h3>${riskTag(n.risk_level)}</div><p><b>核心命题:</b>${esc(n.core_claim)}</p>${tags(n.keywords)}<div class="grow"><div class="detail-grid"><div class="detail-box"><b>常见表达</b>${list(n.common_forms)}</div><div class="detail-box"><b>触发事件</b>${list(n.trigger_events)}</div></div></div><div class="tagrow"><button class="btn ghost" onclick="openNarrative('${esc(n.id)}')">查看叙事详情</button></div></div>`).join('')}</div>` : '<div class="empty">没有匹配叙事。</div>';
$('pane-narratives').innerHTML = sectionTitle('核心叙事库','展示每类叙事的核心命题、关键词、常见表达、触发事件、反叙事、反弹风险和数据缺口。') + html;
}
function renderStructures() {
const items = db.narratives.filter(n=>contains(n,state.search));
$('pane-structures').innerHTML = sectionTitle('叙事结构拆解','将叙事拆成问题定义、责任归因、情绪机制、道德框架、反叙事与数据需求,便于研究而非只看口号。')+
(items.length ? `<div class="grid cols-2">${items.map(n=>`<div class="card"><div class="badge-line"><h3 style="margin-right:auto">${esc(n.name)}</h3>${riskTag(n.risk_level)}</div><p><b>核心命题:</b>${esc(n.core_claim)}</p><div class="detail-grid">${(n.structure_list||[]).map(s=>`<div class="detail-box"><b>${esc(s.label)}</b><p>${esc(s.value)}</p></div>`).join('')}</div><div class="detail-grid"><div class="detail-box"><b>反叙事</b>${list(n.counter_narratives)}</div><div class="detail-box"><b>反弹风险</b>${list(n.backlash_risks)}</div><div class="detail-box"><b>数据缺口</b>${list(n.data_needs)}</div><div class="detail-box"><b>样本采集字段</b>${list(n.sample_collection_fields)}</div></div></div>`).join('')}</div>` : '<div class="empty">没有匹配结构。</div>');
}
function renderPlatforms() {
const items = db.platforms.filter(p=>contains(p,state.search));
$('pane-platforms').innerHTML = sectionTitle('平台传播生态','将平台差异转化为可采集字段:内容形态、叙事类型、监测字段、风险点和分析注意事项。')+
(items.length ? `<div class="grid cols-3">${items.map(p=>`<div class="card"><h3>${esc(p.name)}</h3><div class="detail-box"><b>内容形态</b>${list(p.content_forms)}</div><div class="detail-box"><b>高频叙事</b>${list(p.high_frequency_narratives)}</div><div class="detail-box"><b>监测字段</b>${list(p.monitor_fields)}</div><div class="detail-box"><b>风险点</b>${list(p.risk_points)}</div><div class="detail-box"><b>样本字段</b>${list(p.sample_fields)}</div><div class="detail-box"><b>分析注意</b>${list(p.analysis_notes)}</div></div>`).join('')}</div>` : '<div class="empty">没有匹配平台。</div>');
}
function renderAudiences() {
const items = db.audiences.filter(a=>contains(a,state.search));
$('pane-audiences').innerHTML = sectionTitle('受众反应观察','只做匿名群体层级观察:主要关切、共鸣模块、反感模块、平台分布与伦理边界。')+
(items.length ? `<div class="grid cols-3">${items.map(a=>`<div class="card"><h3>${esc(a.group)}</h3><p><b>反应类型:</b>${esc(a.reaction_type)}</p><div class="detail-box"><b>主要关切</b>${list(a.concerns)}</div><div class="detail-grid"><div class="detail-box"><b>容易共鸣</b>${list(a.accepted_modules)}</div><div class="detail-box"><b>容易反感</b>${list(a.rejected_modules)}</div></div><div class="detail-box"><b>观察平台</b>${tags(a.platforms)}</div><div class="detail-box"><b>伦理边界</b><p>${esc(a.ethics || a.observation_rule)}</p></div></div>`).join('')}</div>` : '<div class="empty">没有匹配群体。</div>');
}
function renderCompeting() {
const items = db.competing_narratives.filter(c=>contains(c,state.search));
$('pane-competing').innerHTML = sectionTitle('竞争叙事分析','呈现不同叙事之间如何互相对抗、校正或引发反弹。')+
(items.length ? `<div class="grid cols-2">${items.map(c=>`<div class="card"><h3>${esc(c.name)}</h3><p><b>核心主张:</b>${esc(c.core_claim)}</p><div class="detail-grid"><div class="detail-box"><b>主要对抗对象</b>${list(c.against)}</div><div class="detail-box"><b>典型证据来源</b>${list(c.evidence_sources)}</div><div class="detail-box"><b>反弹风险</b><p>${esc(c.risk)}</p></div><div class="detail-box"><b>待补数据</b>${list(c.data_needs)}</div></div></div>`).join('')}</div>` : '<div class="empty">没有匹配竞争叙事。</div>');
}
function renderActors() {
const items = db.actors.filter(a=>contains(a,state.search));
$('pane-actors').innerHTML = sectionTitle('主体与传播节点','把公开机构、政党、媒体、事实查核机构、研究机构与国际媒体作为可观察节点管理。')+
(items.length ? `<div class="grid cols-3">${items.map(a=>`<div class="card"><div class="actor-row"><div class="actor-icon">${esc((a.name||'?').slice(0,1))}</div><div><h3>${esc(a.name)}</h3><div class="tagrow"><span class="tag">${esc(a.type)}</span></div></div></div><p><b>主要角色:</b>${esc(a.role)}</p><div class="detail-box"><b>议题范围</b>${list(a.issues)}</div><div class="detail-box"><b>采集字段</b>${list(a.collect_fields)}</div><div class="detail-box"><b>监测维度</b>${list(a.monitor_dimensions)}</div><div class="detail-box"><b>边界提示</b><p>${esc(a.boundary_note)}</p></div></div>`).join('')}</div>` : '<div class="empty">没有匹配主体。</div>');
}
function openModal(title, sub, content) {
$('modalTitle').innerHTML = esc(title); $('modalSub').innerHTML = esc(sub || ''); $('modalContent').innerHTML = content; $('modalBackdrop').classList.add('active');
}
function closeModal() { $('modalBackdrop').classList.remove('active'); }
$('modalBackdrop').addEventListener('click', e => { if(e.target.id === 'modalBackdrop') closeModal(); });
function openEvent(id) {
const e = db.events.find(x=>x.id===id); if(!e) return;
openModal(e.title, `${e.date} · ${e.type} · 风险 ${e.risk_level}/5 · 信心:${e.confidence}`,
`<p>${esc(e.summary)}</p><div class="detail-grid"><div class="detail-box"><b>触发叙事</b>${list(e.triggered_narratives)}</div><div class="detail-box"><b>传播平台</b>${list(e.platforms)}</div><div class="detail-box"><b>相关主体</b>${list(e.actors)}</div><div class="detail-box"><b>观察要点</b>${list(e.observation_points)}</div><div class="detail-box"><b>风险维度</b>${list(e.risk_dimension_text)}</div><div class="detail-box"><b>研究价值</b>${list(e.research_value)}</div><div class="detail-box"><b>后续需补数据</b>${list(e.data_to_add)}</div><div class="detail-box"><b>来源状态</b><p>${esc(e.source_status)}</p></div></div>`);
}
function openNarrative(id) {
const n = db.narratives.find(x=>x.id===id); if(!n) return;
openModal(n.name, `风险 ${n.risk_level}/5 · ${n.id}`,
`<p><b>核心命题:</b>${esc(n.core_claim)}</p>${tags(n.keywords)}<div class="detail-grid"><div class="detail-box"><b>常见表达</b>${list(n.common_forms)}</div><div class="detail-box"><b>触发事件</b>${list(n.trigger_events)}</div>${(n.structure_list||[]).map(s=>`<div class="detail-box"><b>${esc(s.label)}</b><p>${esc(s.value)}</p></div>`).join('')}<div class="detail-box"><b>竞争/反叙事</b>${list(n.counter_narratives)}</div><div class="detail-box"><b>反弹风险</b>${list(n.backlash_risks)}</div><div class="detail-box"><b>数据缺口</b>${list(n.data_needs)}</div><div class="detail-box"><b>字段说明</b>${Object.entries(n.field_notes||{}).map(([k,v])=>`<p><b>${esc(k)}:</b>${esc(v)}</p>`).join('')}</div></div>`);
}
function renderAll() { renderOverview(); renderEvents(); renderNarratives(); renderStructures(); renderPlatforms(); renderAudiences(); renderCompeting(); renderActors(); }
$('globalSearch').addEventListener('input', e => { state.search = e.target.value.trim(); renderAll(); });
initTabs(); initMeta(); renderAll();
</script>
</body>
</html>
@@ -0,0 +1,255 @@
<!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:#7f171d;
--bg2:#b7423d;
--paper:#fffaf2;
--card:#fffdf8;
--line:#ead9c8;
--text:#2a1b1b;
--muted:#7c6860;
--red:#9b1c24;
--red2:#c64d45;
--gold:#d8a24a;
--shadow:0 18px 45px rgba(50,16,18,.18);
--radius:22px;
}
* { box-sizing:border-box; }
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',Arial,sans-serif; color:var(--text); background:linear-gradient(135deg,var(--bg),var(--bg2)); min-height:100vh; }
a { color:var(--red); }
.app { max-width:1480px; margin:0 auto; padding:28px 26px 48px; }
.hero { background:rgba(255,250,242,.96); border:1px solid rgba(255,255,255,.45); border-radius:30px; padding:26px; box-shadow:var(--shadow); position:relative; overflow:hidden; }
.hero:before { content:''; position:absolute; right:-120px; top:-160px; width:420px; height:420px; background:radial-gradient(circle,rgba(216,162,74,.42),rgba(216,162,74,0) 65%); }
.kicker { display:inline-flex; gap:8px; align-items:center; padding:6px 12px; border:1px solid #e4c3b0; background:#fff3e6; border-radius:999px; color:#8d211f; font-size:13px; font-weight:700; }
h1 { margin:14px 0 8px; font-size:34px; line-height:1.2; letter-spacing:.2px; }
.subtitle { max-width:980px; line-height:1.75; color:#614c45; font-size:15px; }
.toolbar { margin-top:20px; display:flex; gap:12px; flex-wrap:wrap; align-items:center; }
.search { flex:1; min-width:260px; position:relative; }
.search input { width:100%; padding:13px 16px 13px 42px; border:1px solid #e3cbbb; background:#fffdfa; border-radius:15px; outline:none; font-size:14px; }
.search span { position:absolute; left:15px; top:12px; opacity:.55; }
select, button { border:1px solid #e3cbbb; background:#fffdfa; border-radius:14px; padding:12px 14px; font-size:14px; color:var(--text); }
button { cursor:pointer; font-weight:700; }
button.primary { background:linear-gradient(135deg,var(--red),var(--red2)); color:white; border:none; }
.stats { display:grid; grid-template-columns:repeat(6,1fr); gap:14px; margin:18px 0; }
.stat { background:rgba(255,250,242,.94); border:1px solid rgba(255,255,255,.5); border-radius:18px; padding:15px; box-shadow:0 8px 24px rgba(50,16,18,.1); }
.stat b { display:block; font-size:28px; color:#8c191e; }
.stat span { color:#725f59; font-size:13px; }
.tabs { display:flex; gap:10px; overflow-x:auto; padding:10px 0 16px; scrollbar-width:thin; }
.tab { white-space:nowrap; border:1px solid rgba(255,255,255,.52); background:rgba(255,250,242,.92); padding:12px 16px; border-radius:999px; font-weight:800; color:#6c2928; cursor:pointer; box-shadow:0 8px 18px rgba(0,0,0,.08); }
.tab.active { background:#2b1616; color:#fffaf2; border-color:#2b1616; }
.panel { display:none; background:var(--paper); border-radius:28px; padding:22px; box-shadow:var(--shadow); border:1px solid rgba(255,255,255,.55); }
.panel.active { display:block; }
.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:18px; border-bottom:1px solid var(--line); padding-bottom:16px; }
.panel-title h2 { margin:0; font-size:24px; }
.panel-title p { margin:7px 0 0; color:var(--muted); line-height:1.7; }
.grid { display:grid; gap:16px; }
.grid.cols-2 { grid-template-columns:repeat(2, minmax(0,1fr)); }
.grid.cols-3 { grid-template-columns:repeat(3, minmax(0,1fr)); }
.grid.cols-4 { grid-template-columns:repeat(4, minmax(0,1fr)); }
.card { background:var(--card); border:1px solid var(--line); border-radius:20px; padding:17px; box-shadow:0 10px 28px rgba(90,40,30,.07); }
.card h3 { margin:0 0 10px; font-size:18px; color:#451b1b; }
.card p { line-height:1.72; color:#604e48; font-size:14px; margin:8px 0; }
.meta { display:flex; flex-wrap:wrap; gap:7px; margin:10px 0; }
.badge { display:inline-flex; align-items:center; gap:4px; padding:5px 9px; border-radius:999px; background:#f7eadc; color:#7a2725; border:1px solid #ead2be; font-size:12px; font-weight:700; }
.badge.dark { background:#2d1919; color:white; border-color:#2d1919; }
.badge.gold { background:#fff1cf; color:#835513; border-color:#f1d99b; }
.table-wrap { overflow:auto; border:1px solid var(--line); border-radius:18px; background:white; }
table { border-collapse:collapse; width:100%; min-width:850px; }
th,td { padding:12px 13px; border-bottom:1px solid #f0e1d4; text-align:left; vertical-align:top; font-size:13px; line-height:1.55; }
th { position:sticky; top:0; background:#fff2e5; color:#5d201f; z-index:1; }
tr:hover td { background:#fffaf4; }
.event-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
.event-card { cursor:pointer; transition:.18s transform,.18s box-shadow; }
.event-card:hover { transform:translateY(-2px); box-shadow:0 15px 32px rgba(90,30,30,.12); }
.event-head { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; }
.event-id { font-weight:900; color:#981b20; }
.time { color:#80645a; font-size:13px; font-weight:700; }
.timeline { position:relative; padding-left:20px; }
.timeline:before { content:''; position:absolute; left:6px; top:8px; bottom:8px; width:2px; background:#e4c6b1; }
.timeline-item { position:relative; padding:0 0 15px 18px; }
.timeline-item:before { content:''; position:absolute; left:-19px; top:5px; width:12px; height:12px; border-radius:50%; background:#9b1c24; border:3px solid #f8e7d5; }
.timeline-item b { color:#4a1d1d; }
.notice { background:#fff4dc; border:1px solid #efd49d; border-radius:18px; padding:14px 16px; color:#6b4b18; line-height:1.7; }
.modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.5); display:none; align-items:center; justify-content:center; padding:20px; z-index:20; }
.modal { max-width:840px; width:100%; max-height:86vh; overflow:auto; background:var(--paper); border-radius:24px; padding:22px; box-shadow:0 35px 90px rgba(0,0,0,.35); }
.modal h2 { margin:0 0 8px; }
.modal-close { float:right; border:none; background:#2d1919; color:white; padding:8px 12px; border-radius:999px; }
.detail-grid { display:grid; grid-template-columns:130px 1fr; gap:10px; margin-top:15px; }
.detail-grid div { border-bottom:1px solid #ead9c8; padding:9px 0; line-height:1.65; }
.detail-grid .label { color:#7a5148; font-weight:900; }
.footer { color:#fff4e8; opacity:.92; text-align:center; margin-top:24px; font-size:13px; line-height:1.8; }
.empty { padding:32px; text-align:center; color:#795e55; }
@media(max-width:1100px) { .stats { grid-template-columns:repeat(3,1fr); } .grid.cols-3,.grid.cols-4 { grid-template-columns:repeat(2,1fr); } .event-list { grid-template-columns:1fr; } }
@media(max-width:720px) { .app { padding:16px; } h1 { font-size:26px; } .stats { grid-template-columns:repeat(2,1fr); } .grid.cols-2,.grid.cols-3,.grid.cols-4 { grid-template-columns:1fr; } .panel { padding:16px; } .detail-grid { grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="app">
<section class="hero">
<span class="kicker">专题数据库 · JSON + HTML 离线版</span>
<h1>台湾经济安全与公共叙事研究专题</h1>
<div class="subtitle">围绕台积电赴美、CHIPS Act、军费军购、台美贸易倡议、CPTPP/RCEP、ECFA、两岸经贸、台商供应链与平台舆情进行结构化整理。页面强调事实边界,避免把事件直接写成单向结论。</div>
<div class="toolbar">
<div class="search"><span>⌕</span><input id="globalSearch" placeholder="搜索事件、叙事、企业、人物、平台关键词……" /></div>
<select id="categoryFilter"><option value="">全部事件分类</option></select>
<button class="primary" onclick="downloadJSON()">导出当前 JSON</button>
<button onclick="resetFilters()">重置筛选</button>
</div>
</section>
<section class="stats" id="stats"></section>
<nav class="tabs" id="tabs"></nav>
<main id="panels"></main>
<div class="footer">生成日期:2026-06-25 · 使用边界:公开资料研究、事实核查、政策与产业分析,不用于欺骗性影响行动或定向操控。</div>
</div>
<div class="modal-backdrop" id="modalBackdrop" onclick="closeModal(event)">
<div class="modal" id="modal"></div>
</div>
<script>
const DATA = {"metadata": {"title": "台湾经济安全与公共叙事研究专题数据库", "version": "1.0", "generated_date": "2026-06-25", "language": "zh-CN", "scope": "公开资料研究、事实核查、产业与经贸安全、军费军购、两岸经贸、平台生态与竞争叙事分析", "safety_boundary": "研究型、分析型、非欺骗、非煽动、非操控;区分事实、观点、归因、推论与未确认信息。", "source_note": "本库由用户提供的 skill 框架与本轮补充数据整理而成;部分事件为待持续更新项,页面以“事实边界”提示不可过度推论。"}, "modules": [{"id": "M01", "name": "专题总览与议题地图", "desc": "说明台湾经济安全叙事的整体结构,呈现产业、军费、经贸、财政、台美关系和两岸关系等主线。"}, {"id": "M02", "name": "核心叙事结构库", "desc": "拆解台积电赴美、硅盾、军购延迟、军费挤压、台美贸易、RCEP/CPTPP、台商两难、前线化等叙事。"}, {"id": "M03", "name": "关键事件与证据库", "desc": "建立事件卡、时间线和事实边界,标注事件能支撑什么、不能证明什么。"}, {"id": "M04", "name": "半导体与产业安全", "desc": "研究台积电赴美、半导体供应链、人才外流、硅盾争议和台湾产业根基。"}, {"id": "M05", "name": "军购、军费与财政压力", "desc": "研究对美军购、国防预算、交付延迟、战力形成与财政排挤争议。"}, {"id": "M06", "name": "区域经贸与两岸经济", "desc": "研究台美贸易倡议、CPTPP、RCEP、ECFA、两岸贸易和供应链转移。"}, {"id": "M07", "name": "企业、台商与产业组织", "desc": "研究重点企业、台商、工商团体、产业协会的利益和结构性压力。"}, {"id": "M08", "name": "人物、政党、媒体与组织网络", "desc": "研究台湾政党人物、官员、媒体、智库、事实核查组织与美国相关人物。"}, {"id": "M09", "name": "受众群体与平台生态", "desc": "研究不同群体如何接收经济安全叙事,以及不同平台上的表达方式。"}, {"id": "M10", "name": "竞争叙事、事实核查与风险边界", "desc": "比较不同阵营对同一事件的解释,标注事实、推论、过度归因和稳健表达。"}], "issue_map": [{"id": "I01", "name": "台积电赴美与硅盾变化", "desc": "研究台积电海外先进制程布局是否影响台湾产业安全与硅盾叙事。", "actors": ["台积电", "美国商务部", "台湾经济部门", "半导体供应链"], "indicators": ["海外厂制程", "台湾本土先进制程", "研发留台程度", "补贴条件"]}, {"id": "I02", "name": "半导体人才与技术外流", "desc": "研究工程师外派、美国本地招聘和制造经验转移是否削弱台湾本土人才密度。", "actors": ["台积电", "工程师群体", "美国亚利桑那地方政府"], "indicators": ["外派人数", "招聘岗位", "训练计划", "薪资结构"]}, {"id": "I03", "name": "CHIPS Act 与美国产业政策", "desc": "研究美国补贴条件、供应链回流政策对台湾企业的约束与机会。", "actors": ["美国商务部", "TSMC Arizona", "美国客户"], "indicators": ["补贴金额", "拨付条件", "限制条款", "项目里程碑"]}, {"id": "I04", "name": "对美军购与交付延迟", "desc": "研究台湾采购美国武器是否存在价格、交付和战力形成争议。", "actors": ["台湾防务部门", "美国军工企业", "DSCA", "AIT"], "indicators": ["军售金额", "交付时间", "延迟原因", "训练完成情况"]}, {"id": "I05", "name": "军费上升与民生财政竞争", "desc": "研究国防支出上升是否挤压社会福利、教育、医疗、产业升级预算。", "actors": ["台湾财政部门", "防务部门", "立法机构"], "indicators": ["GDP占比", "特别预算", "举债", "民生预算变化"]}, {"id": "I06", "name": "台美贸易倡议是否对等", "desc": "研究台美21世纪贸易倡议是否带来关税红利或只是制度对接。", "actors": ["USTR", "TECRO", "AIT", "台湾经贸部门"], "indicators": ["协议章节", "关税减免", "企业受益", "规则成本"]}, {"id": "I07", "name": "CPTPP/RCEP 与区域整合缺口", "desc": "研究台湾未加入RCEP、CPTPP迟迟未入会对传统产业的影响。", "actors": ["台湾经济部门", "传统产业", "CPTPP成员"], "indicators": ["关税差距", "订单转移", "传统产业出口表现"]}, {"id": "I08", "name": "两岸贸易依赖变化", "desc": "研究台湾对大陆、香港、美国、东盟出口结构变化。", "actors": ["出口商", "台商", "传统制造业", "高科技产业"], "indicators": ["出口占比", "贸易顺差", "产业类别"]}, {"id": "I09", "name": "台商供应链转移与选边压力", "desc": "研究台商在大陆、美国、东南亚之间的市场和政治压力。", "actors": ["台商协会", "电子代工", "传统产业"], "indicators": ["投资流向", "迁厂Line truncated
const TAB_CONFIG = [
['overview','总览'], ['issues','议题地图'], ['narratives','叙事结构'], ['events','关键事件'], ['semiconductor','半导体产业'], ['defense','军购预算'], ['trade','经贸区域'], ['orgs','企业组织'], ['actors','人物网络'], ['audience','受众平台'], ['facts','事实核查']
];
let activeTab = 'overview';
let searchTerm = '';
let categoryFilter = '';
function esc(s) { return String(s ?? '').replace(/[&<>'"]/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[ch])); }
function arr(a) { return Array.isArray(a) ? a : (a ? [a] : []); }
function badges(items, cls='') { return arr(items).map(x => `<span class="badge ${cls}">${esc(x)}</span>`).join(''); }
function contains(obj, q) { if(!q) return true; return JSON.stringify(obj).toLowerCase().includes(q.toLowerCase()); }
function filteredEvents() { return DATA.events.filter(e => (!categoryFilter || e.category === categoryFilter) && contains(e, searchTerm)); }
function filtered(list) { return list.filter(x => contains(x, searchTerm)); }
function init() {
renderStats(); renderTabs(); fillFilter(); renderPanels(); bind();
}
function bind() {
document.getElementById('globalSearch').addEventListener('input', e => { searchTerm = e.target.value.trim(); renderPanels(); });
document.getElementById('categoryFilter').addEventListener('change', e => { categoryFilter = e.target.value; renderPanels(); });
}
function renderStats() {
const s = [
['研究栏目', DATA.modules.length], ['核心叙事', DATA.narratives.length], ['关键事件', DATA.events.length], ['企业样本', DATA.companies.length], ['组织样本', DATA.organizations.length], ['人物样本', DATA.actors.length]
];
document.getElementById('stats').innerHTML = s.map(([k,v]) => `<div class="stat"><b>${v}</b><span>${k}</span></div>`).join('');
}
function renderTabs() {
document.getElementById('tabs').innerHTML = TAB_CONFIG.map(([id,name]) => `<button class="tab ${id===activeTab?'active':''}" onclick="setTab('${id}')">${name}</button>`).join('');
}
function setTab(id) { activeTab = id; renderTabs(); renderPanels(); window.scrollTo({top:190,behavior:'smooth'}); }
function fillFilter() {
const cats = [...new Set(DATA.events.map(e=>e.category))].sort();
document.getElementById('categoryFilter').innerHTML = '<option value="">全部事件分类</option>' + cats.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('');
}
function resetFilters() { searchTerm=''; categoryFilter=''; document.getElementById('globalSearch').value=''; document.getElementById('categoryFilter').value=''; renderPanels(); }
function panel(title, desc, body) { return `<section class="panel active"><div class="panel-title"><div><h2>${title}</h2><p>${desc}</p></div></div>${body}</section>`; }
function renderPanels() {
const root = document.getElementById('panels');
if(activeTab==='overview') root.innerHTML = renderOverview();
if(activeTab==='issues') root.innerHTML = renderIssues();
if(activeTab==='narratives') root.innerHTML = renderNarratives();
if(activeTab==='events') root.innerHTML = renderEvents();
if(activeTab==='semiconductor') root.innerHTML = renderSemiconductor();
if(activeTab==='defense') root.innerHTML = renderDefense();
if(activeTab==='trade') root.innerHTML = renderTrade();
if(activeTab==='orgs') root.innerHTML = renderOrgs();
if(activeTab==='actors') root.innerHTML = renderActors();
if(activeTab==='audience') root.innerHTML = renderAudience();
if(activeTab==='facts') root.innerHTML = renderFacts();
}
function renderOverview() {
const body = `<div class="notice"><b>资料边界:</b>${esc(DATA.metadata.safety_boundary)} 本页面保留来源链接和事实边界字段,适合作为专题研究初版数据库,不应替代逐条原始资料核验。</div>
<div class="grid cols-2" style="margin-top:16px">${DATA.modules.map(m=>`<div class="card"><div class="meta"><span class="badge dark">${m.id}</span></div><h3>${esc(m.name)}</h3><p>${esc(m.desc)}</p></div>`).join('')}</div>`;
return panel('专题总览', '10个一级栏目与数据库结构,可作为后续JSON扩展和HTML页面长期更新的基础。', body);
}
function renderIssues() {
const list = filtered(DATA.issue_map);
const body = `<div class="grid cols-2">${list.map(i=>`<div class="card"><div class="meta"><span class="badge dark">${i.id}</span>${badges(i.indicators,'gold')}</div><h3>${esc(i.name)}</h3><p>${esc(i.desc)}</p><p><b>关联对象:</b>${esc(i.actors.join('、'))}</p></div>`).join('') || '<div class="empty">没有匹配结果</div>'}</div>`;
return panel('议题地图', '把产业、经贸、军费、两岸关系和安全焦虑拆成可观察的研究议题。', body);
}
function renderNarratives() {
const list = filtered(DATA.narratives);
const body = `<div class="grid cols-2">${list.map(n=>`<div class="card"><div class="meta"><span class="badge dark">${n.id}</span>${badges(n.keywords)}</div><h3>${esc(n.name)}</h3><p><b>核心主张:</b>${esc(n.claim)}</p><p><b>情绪基础:</b>${esc(n.emotion.join('、'))}</p><p><b>反方解释:</b>${esc(n.counter)}</p><p><b>事实边界:</b>${esc(n.boundary)}</p></div>`).join('') || '<div class="empty">没有匹配结果</div>'}</div>`;
return panel('核心叙事结构库', '每条叙事均包含核心主张、关键词、情绪基础、支撑事实、反方解释和事实边界。', body);
}
function renderEvents() {
const list = filteredEvents();
const body = `<div class="notice">当前显示 <b>${list.length}</b> 条事件。点击任一事件卡可查看完整字段、关联叙事、来源链接与事实边界。</div><div class="event-list" style="margin-top:15px">${list.map(e=>eventCard(e)).join('') || '<div class="empty">没有匹配结果</div>'}</div>`;
return panel('关键事件与证据库', 'E01—E80事件库,覆盖半导体、台美经贸、区域协定、ECFA、军费军购与安全环境。', body);
}
function eventCard(e) {
return `<div class="card event-card" onclick="showEvent('${e.id}')"><div class="event-head"><span class="event-id">${e.id}</span><span class="time">${esc(e.time)}</span></div><div class="meta"><span class="badge gold">${esc(e.category)}</span>${badges(e.supports_narratives,'dark')}</div><h3>${esc(e.name)}</h3><p>${esc(e.summary)}</p><p><b>事实边界:</b>${esc(e.fact_boundary)}</p></div>`;
}
function showEvent(id) {
const e = DATA.events.find(x=>x.id===id); if(!e) return;
const src = arr(e.source_urls).map(u=>`<a href="${esc(u)}" target="_blank">${esc(u)}</a>`).join('<br>') || '待补充';
const html = `<button class="modal-close" onclick="closeModal()">关闭</button><h2>${esc(e.id)} · ${esc(e.name)}</h2><div class="meta"><span class="badge gold">${esc(e.category)}</span>${badges(e.supports_narratives,'dark')}</div><div class="detail-grid"><div class="label">时间</div><div>${esc(e.time)}</div><div class="label">事件摘要</div><div>${esc(e.summary)}</div><div class="label">可支撑叙事</div><div>${esc(e.supports_narratives.join('、'))}</div><div class="label">事实边界</div><div>${esc(e.fact_boundary)}</div><div class="label">来源链接</div><div>${src}</div><div class="label">可信度</div><div>${esc(e.credibility)}</div></div>`;
document.getElementById('modal').innerHTML = html;
document.getElementById('modalBackdrop').style.display='flex';
}
function closeModal(ev) { if(!ev || ev.target.id==='modalBackdrop' || ev.target.className==='modal-close') document.getElementById('modalBackdrop').style.display='none'; }
function renderSemiconductor() {
const list = filtered(DATA.companies.filter(c=>/晶圆|IC|封测|硅|半导体|台积电|联发科|日月光|环球/.test(c.sector+c.name)));
const body = `<div class="grid cols-3">${list.map(c=>`<div class="card"><div class="meta"><span class="badge dark">${c.id}</span>${badges(c.narratives)}</div><h3>${esc(c.name)}</h3><p><b>产业位置:</b>${esc(c.sector)}</p><p>${esc(c.focus)}</p></div>`).join('')}</div>`;
return panel('半导体与产业安全', '聚焦台积电、先进制程、半导体供应链、人才外流和硅盾争议。', body);
}
function renderDefense() {
const list = filteredEvents().filter(e=>e.category==='军费军购与预算');
const body = `<div class="event-list">${list.map(e=>eventCard(e)).join('') || '<div class="empty">没有匹配结果</div>'}</div>`;
return panel('军购、军费与财政压力', '集中展示防务特别预算、对美军购、交付延迟、后勤依赖和战力形成边界。', body);
}
function renderTrade() {
const indicators = filtered(DATA.trade_indicators);
const events = filteredEvents().filter(e=>/经贸|区域|贸易|ECFA|协定/.test(e.category+e.name));
const table = `<div class="table-wrap"><table><thead><tr><th>编号</th><th>指标</th><th>年份</th><th>数值</th><th>研究意义</th></tr></thead><tbody>${indicators.map(t=>`<tr><td>${t.id}</td><td>${esc(t.name)}</td><td>${esc(t.year)}</td><td>${esc(t.value)}</td><td>${esc(t.meaning)}</td></tr>`).join('')}</tbody></table></div>`;
const cards = `<div class="event-list" style="margin-top:16px">${events.map(e=>eventCard(e)).join('')}</div>`;
return panel('区域经贸与两岸经济', '包含贸易指标、台美贸易倡议、RCEP/CPTPP、ECFA与两岸贸易结构变化。', table + cards);
}
function renderOrgs() {
const companies = filtered(DATA.companies);
const orgs = filtered(DATA.organizations);
const comp = `<h3>企业样本</h3><div class="grid cols-3">${companies.map(c=>`<div class="card"><div class="meta"><span class="badge dark">${c.id}</span>${badges(c.narratives)}</div><h3>${esc(c.name)}</h3><p><b>产业位置:</b>${esc(c.sector)}</p><p>${esc(c.focus)}</p></div>`).join('')}</div>`;
const orgHtml = `<h3 style="margin-top:24px">组织样本</h3><div class="grid cols-3">${orgs.map(o=>`<div class="card"><div class="meta"><span class="badge gold">${o.id}</span><span class="badge">${esc(o.type)}</span></div><h3>${esc(o.name)}</h3><p>${esc(o.focus)}</p></div>`).join('')}</div>`;
return panel('企业、台商与产业组织', '企业与产业组织是叙事落地的重要利益主体,需要继续补公开表态和政策诉求。', comp + orgHtml);
}
function renderActors() {
const list = filtered(DATA.actors);
const body = `<div class="grid cols-3">${list.map(p=>`<div class="card"><div class="meta"><span class="badge dark">${p.id}</span>${badges(p.issues,'gold')}</div><h3>${esc(p.name)}</h3><p><b>身份:</b>${esc(p.role)}</p><p>${esc(p.note)}</p></div>`).join('') || '<div class="empty">没有匹配结果</div>'}</div>`;
return panel('人物、政党、媒体与组织网络', '当前先放官方人物样本;后续应扩充政党、立法机构、政论人物、财经媒体、智库和事实查核组织。', body);
}
function renderAudience() {
const audiences = filtered(DATA.audiences);
const platforms = filtered(DATA.platforms);
const ahtml = `<h3>受众群体</h3><div class="grid cols-2">${audiences.map(g=>`<div class="card"><div class="meta"><span class="badge dark">${g.id}</span>${badges(g.concerns)}</div><h3>${esc(g.name)}</h3><p><b>容易共鸣:</b>${esc(g.resonant_issues.join('、'))}</p><p><b>反弹风险:</b>${esc(g.risk)}</p></div>`).join('')}</div>`;
const phtml = `<h3 style="margin-top:24px">平台采样计划</h3><div class="grid cols-4">${platforms.map(p=>`<div class="card"><div class="meta"><span class="badge gold">目标 ${p.sample_target} 条</span></div><h3>${esc(p.name)}</h3><p>${esc(p.focus)}</p><p><b>字段:</b>${esc(p.fields.join('、'))}</p></div>`).join('')}</div>`;
return panel('受众群体与平台生态', '受众画像用于理解议题接收方式;平台数据只应作为舆情样本,不能代表整体民意。', ahtml + phtml);
}
function renderFacts() {
const checks = filtered(DATA.fact_checks);
const gaps = DATA.data_gaps;
const table = `<div class="table-wrap"><table><thead><tr><th>争议说法</th><th>事实状态</th><th>建议稳健表达</th><th>高风险表达</th></tr></thead><tbody>${checks.map(f=>`<tr><td>${esc(f.claim)}</td><td>${esc(f.status)}</td><td>${esc(f.safe_expression)}</td><td>${esc(f.risky_expression)}</td></tr>`).join('')}</tbody></table></div>`;
const gapHtml = `<h3 style="margin-top:24px">后续数据缺口</h3><div class="grid cols-3">${gaps.map(g=>`<div class="card"><div class="meta"><span class="badge ${g.priority==='高'?'dark':'gold'}">${esc(g.priority)}优先级</span></div><h3>${esc(g.name)}</h3><p>${esc(g.target)}</p></div>`).join('')}</div>`;
return panel('竞争叙事、事实核查与风险边界', '该栏目用于把绝对化、阴谋化或证据不足的表达改写成研究型表述。', table + gapHtml);
}
function downloadJSON() {
const blob = new Blob([JSON.stringify(DATA,null,2)], {type:'application/json;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href=url; a.download='taiwan_economic_security_narrative_database.json'; a.click(); URL.revokeObjectURL(url);
}
init();
</script>
</body>
</html>
@@ -0,0 +1,165 @@
"""researcher_node:检索类型 `knowledge_base`(知识库检索)= 直连内网 ES 接口。
弱模型识别不了技能时用这条路:不编排任何技能、不跑通用 provider,
直接调 ai_writing.intranet_search_url 内网知识库 ES 接口检索。
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from deerflow.agents.ai_writing.nodes import researcher as R
from deerflow.agents.ai_writing.search.base import SearchResult
class _FakeIntranet:
instances: list = []
def __init__(self, **kw):
self.errors = []
self.raw_responses = []
self.calls = []
self.kw = kw
_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 = []
raw_responses: list = []
async def search(self, query, max_results=5, **_): # pragma: no cover - 不应被调用
raise AssertionError("knowledge_base 模式不应调用通用 provider / 技能检索")
def _patch(monkeypatch, intranet_url="http://es/api", intranet_cls=_FakeIntranet):
_FakeIntranet.instances = []
cfg = {
"max_materials": 20,
"intranet_search_url": intranet_url,
"intranet_knowledge_base": "samples",
}
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):
return {"keywords": ["特朗普竞选"]}
monkeypatch.setattr(R, "llm_json", fake_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",
intranet_cls,
)
def _state(material_source="knowledge_base"):
return {
"user_intent": "分析特朗普近期动向",
"article_type": "政治分析",
"target_audience": "普通读者",
"keyword_count": 1,
"material_source": material_source,
}
@pytest.mark.asyncio
async def test_knowledge_base_hits_intranet_directly(monkeypatch):
_patch(monkeypatch)
out = await R.researcher_node(_state(), {"configurable": {}})
mats = out["material_package"]["materials"]
assert mats, "知识库检索应返回内网素材"
assert all(m["source_type"] == "intranet" for m in mats)
assert mats[0]["title"] == "内网-特朗普竞选"
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
async def test_knowledge_base_no_url_falls_back_to_default_endpoint(monkeypatch):
"""未配置接口地址:仍实例化 IntranetSearchProvider(base_url 传空串,由 provider 内部
回退默认内网端点),内网直连依然可用——恢复旧版 search_provider=intranet 的行为。"""
_patch(monkeypatch, intranet_url="")
out = await R.researcher_node(_state(), {"configurable": {}})
mats = out["material_package"]["materials"]
assert mats, "无地址时应回退默认内网端点,仍返回素材"
assert all(m["source_type"] == "intranet" for m in mats)
assert _FakeIntranet.instances, "应实例化 IntranetSearchProvider 直连内网接口"
assert _FakeIntranet.instances[0].kw.get("base_url") == "", "空地址应原样下传,由 provider 回退默认端点"
@pytest.mark.asyncio
async def test_knowledge_base_does_not_orchestrate_skills(monkeypatch):
"""knowledge_base 不编排技能:通用 provider 一旦被调用就会 raise。"""
_patch(monkeypatch)
# 不抛异常即证明没走 _SkillProviderShouldNotRun
out = await R.researcher_node(_state(), {"configurable": {}})
assert out["material_package"]["materials"]
@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"])
@@ -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
+192
View File
@@ -0,0 +1,192 @@
# xdfx 单技能全流程使用说明
## 适用场景
使用 `xdfx-single-agent-full-flow` 技能,让单智能体一次性完成 xdfx 深链接模式下的完整分析与入库流程:
1. 受众目标分析,选出最优 3 个受众并入库。
2. 关键传播节点分析,跨受众去重后选出最优 3 个节点并入库。
3. 受众脆弱点分析并入库。
4. 关键传播节点脆弱点分析并入库。
5. 受众策略分析并入库。
6. 关键传播节点策略分析并入库。
7. 同步前端步骤状态,保证原 `item-aiceh` 页面步骤条可继续识别。
## 技能位置
```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"
}
```
默认会自动处理登录态:当命令没有传 `Authorization`,或某个接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`。拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。
默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;如果部署环境证书链正常,可以追加 `--verify-ssl`。如果已经有可用 token,也可以继续追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`,脚本会优先使用传入 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 传递、断点续跑和最终入库。
每完成一步都会输出一行:
```text
STEP_FEEDBACK {...}
```
调用技能的智能体必须在每个 step 命令结束后读取 `STEP_FEEDBACK`,给用户一次反馈,再执行下一条 step 命令。
每次执行具体操作或调用接口前,脚本会输出:
```text
OP_FEEDBACK {...}
```
调用技能的智能体必须把 `operation`、`method` 和 `url` 转述给用户,例如:
```text
正在保存受众目标,调用 POST https://.../api/task/task-action-design。
```
如果报错,脚本会立即输出:
```text
ERROR_FEEDBACK {...}
```
调用技能的智能体必须立刻告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果模型 JSON 经过自动提取和修复仍无法解析,脚本会输出:
```text
JSON_PARSE_REVIEW {...}
```
调用技能的智能体必须查看 `responseExcerpt`,判断是否能识别真正 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` 字段
## 输出文件
执行后查看:
```text
xdfx-flow-state.json
xdfx-flow-summary.md
```
`xdfx-flow-state.json` 保存断点状态、接口返回 id、模型解析结果与错误明细。
`xdfx-flow-summary.md` 是给用户看的执行摘要。
## 每步反馈
脚本会输出三类反馈行:
- `OP_FEEDBACK`:当前正在做什么、调用哪个接口。
- `ERROR_FEEDBACK`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
- `JSON_PARSE_REVIEW`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
- `STEP_FEEDBACK`:当前步骤最终状态和累计入库数量。
`STEP_FEEDBACK` 中包含:
- `step`:步骤编号。
- `label`:步骤名称。
- `status`:`ok`、`partial` 或 `skipped`。
- `counts`:当前累计入库数量。
- `newErrors`:本步骤新增错误数。
- `totalErrors`:累计错误数。
智能体反馈用户时建议使用这种格式:
```text
Step1 受众目标分析完成:当前已入库受众 X 个,本步骤新增错误 Y 个。
```
如果 `status=partial`,要补一句“该步骤部分完成”,并说明稍后最终汇总会列出错误明细。
如果 `status=skipped`,要说明跳过原因。
## 容错规则
- 单个受众、账号、传播节点、脆弱点或策略失败,不中断整个流程。
- 保存接口必须返回 `id`,否则不会进入下游步骤。
- 模型流式 `answer` 会自动拼接。
- 流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 模型输出会自动去掉 `<think>`,从前后混杂文本、多个 JSON 代码块、无换行流式文本、连续 JSON 对象、非工整 JSON 中提取多个候选,并按当前步骤所需字段选择最像的 JSON。
- 模型 JSON 会尝试修复中文引号、全角冒号/逗号、尾随逗号、未加引号的键、Python 风格 `True/False/None`。
- 模型 JSON 解析失败会自动重试 1 次。
- 每一步完成都会写入 state/summary 并输出 `STEP_FEEDBACK`。
- 接口失败会写入 `errors`,包含阶段、URL、状态码、请求摘要、响应正文和异常类型。
## 回复用户规则
执行结束后,智能体必须读取 `xdfx-flow-summary.md` 和 `xdfx-flow-state.json.errors` 再回复用户。
如果 `errors` 为空,可以说明“全流程完成”。
如果 `errors` 非空,只能说明“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
@@ -0,0 +1,111 @@
---
name: xdfx-single-agent-full-flow
description: xdfx 深链接单智能体一键完成受众、关键传播节点、脆弱点与策略分析,并把结果写入行动设计相关接口。
---
# xdfx 单智能体全流程分析入库 Skill
这个 Skill 必须优先运行内置 Python 执行器,不要让模型照着说明手写接口调用。长流程、接口字段、JSON 修复、重试、id 传递、部分失败继续、详细错误汇报都已经固化在:
```text
scripts/xdfx_flow.py
```
## 使用前准备
准备一个 JSON 输入文件,例如 `xdfx-input.json`:
```json
{
"api_base": "https://ch1.b.uat.4.cn",
"taskId": "真实任务id",
"actionId": "真实xdfx行动id",
"mubiao": "传播...叙事,驱动...行为体产生...预期行为,实现...作战目的。",
"direction": "U方向",
"platform": "fb"
}
```
默认会在缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 调用:
```text
POST /api/v1/auth/login/username?password=ch%40user
body: {"username":"admin"}
```
拿到 `access_token` 后会写入 `Authorization: Bearer ...` 并重试原接口一次。登录接口和 401 重试都会输出 `OP_FEEDBACK` / `ERROR_FEEDBACK`,但反馈中的 `password`、`token` 会脱敏。默认关闭 HTTPS 证书校验,适配 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。
如果已经有可用登录态,也可以在命令里追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`;此时脚本优先使用传入的 token。
## 执行命令
从本 Skill 目录逐步执行。虽然这是“一个技能做完整流程”的版本,但为了给用户真实的逐步反馈,智能体必须按 step1-step6 分六个命令运行,每个命令结束后先把 `STEP_FEEDBACK` / 新增错误反馈给用户,再继续下一步:
```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 传递和断点续跑不受影响。
每一步完成后都会输出一行 `STEP_FEEDBACK {...}`。智能体必须在每个 step 命令结束后给用户一次简短反馈,说明该步骤状态、已入库数量和新增错误数,然后再执行下一步。
脚本每次调用接口前还会输出 `OP_FEEDBACK {...}`,包含当前操作、HTTP 方法和 URL。智能体必须用简短中文告诉用户“正在做什么、调用哪个接口”。
脚本出现错误时会立即输出 `ERROR_FEEDBACK {...}`,包含阶段、接口 URL、状态码、请求摘要、响应摘要和异常类型。智能体必须立刻告诉用户错误细节,不能等到最后才说。
如果模型返回内容经过多候选 JSON 提取和修复后仍无法解析,脚本会输出 `JSON_PARSE_REVIEW {...}`。智能体必须查看其中的 `responseExcerpt`,判断是否能识别出可用 JSON;无法确认时,重跑当前步骤或重新调用对应接口。
只做参数和脚本检查、不调用接口:
```bash
python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run
```
## 脚本保证
- 受众只取最优 3 个继续。
- 关键传播节点全局去重后只取最优 3 个继续。
- 每个保存接口必须拿到返回 `id` 才进入下游。
- 保存后会同步前端 `task/task-action.step` 步骤状态;如只想入库不更新步骤条,追加 `--no-step-status`。
- 策略保存会同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`,兼容策略表格/编排页面读取。
- 模型流式 `{"answer":"..."}` 会自动拼接。
- 流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 模型输出会自动去掉 `<think>`,从前后混杂文本、多个 JSON 代码块、无换行流式文本、连续 JSON 对象、非工整 JSON 中提取多个候选,并按当前步骤所需字段选择最像的 JSON。
- 模型 JSON 会尝试修复中文引号、全角冒号/逗号、尾随逗号、未加引号的键、Python 风格 `True/False/None`。
- 模型 JSON 解析失败会自动重试 1 次。
- 单个受众、账号、节点或策略失败不会拖垮全流程。
- 每完成一步都会输出 `STEP_FEEDBACK`,包含 `step`、`label`、`status`、累计入库计数和新增错误数。
- 每次接口调用前都会输出 `OP_FEEDBACK`,说明当前操作和接口 URL。
- 每个错误都会立即输出 `ERROR_FEEDBACK`,说明失败阶段、接口、状态码、请求摘要和响应摘要。
- 所有接口错误都会写入 `xdfx-flow-state.json.errors`,包含阶段、URL、状态码、请求摘要、响应正文和异常类型。
## 回复用户
执行过程中,每出现一行 `STEP_FEEDBACK`,都要立刻向用户反馈一次:
- 当前完成的步骤名称。
- 状态:`ok`、`partial` 或 `skipped`。
- 当前累计入库数量。
- 本步骤新增错误数。
每出现一行 `OP_FEEDBACK`,都要简短告诉用户当前正在执行的操作和接口,例如“正在保存受众目标,调用 POST /api/task/task-action-design”。
每出现一行 `ERROR_FEEDBACK`,都要立刻告诉用户失败阶段、接口 URL、状态码和响应摘要。
每出现一行 `JSON_PARSE_REVIEW`,都要查看 `responseExcerpt`。如果能明显识别出目标 JSON,说明需要人工复核;如果不能识别,重跑当前 step 或重新调用对应模型接口,并告诉用户“模型 JSON 不工整,正在复核并重试”。
全部执行结束后再读取 `xdfx-flow-summary.md` 和命令输出,向用户说明:
- 受众入库数量和 `designId`
- 传播节点入库数量和 `pathwayAccountId`
- 受众脆弱点数量
- 传播节点脆弱点数量
- 策略数量
- 失败明细
只要 `errors` 不为空,就说“部分完成”,不要说“全部完成”。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,75 @@
---
name: xdfx-split-controller
description: xdfx 深链接单智能体拆分版总控技能,按顺序调度 step1-step6 技能完成分析与入库。
---
# xdfx 拆分版总控 Skill
总控也必须优先运行内置 Python 执行器,不要让模型手写接口流程。执行器位于:
```text
scripts/xdfx_flow.py
```
## 输入文件
准备 `xdfx-input.json`:
```json
{
"api_base": "https://ch1.b.uat.4.cn",
"taskId": "真实任务id",
"actionId": "真实xdfx行动id",
"mubiao": "传播...叙事,驱动...行为体产生...预期行为,实现...作战目的。",
"direction": "U方向",
"platform": "fb"
}
```
## 推荐执行顺序
默认会自动处理登录态:当命令没有传 `Authorization`,或某个接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`。拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。默认关闭 HTTPS 证书校验以兼容 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。如果已有可用 token,可追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`。
总控按顺序执行下面 6 条命令,所有步骤共用同一个 `xdfx-flow-state.json`:
```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
```
每条命令结束都会输出一行 `STEP_FEEDBACK {...}`。总控必须在每个 step 命令结束后立即反馈用户,再继续下一步。
每次接口调用前会输出 `OP_FEEDBACK {...}`。总控必须告诉用户当前正在做什么、调用哪个接口。
如果接口或模型处理报错,会立即输出 `ERROR_FEEDBACK {...}`。总控必须立刻把失败阶段、URL、状态码、请求摘要和响应摘要告诉用户。
如果模型返回文本经过自动修复仍无法解析,会输出 `JSON_PARSE_REVIEW {...}`。总控必须查看响应片段,判断是否需要重跑当前 step 或重新调用对应接口。
如果只想一条命令跑完,也可以使用下面命令做无人值守兜底,但不建议作为默认方式,因为外层工具通常会等命令结束后才返回输出,无法保证用户看到实时反馈:
```bash
python scripts/xdfx_flow.py --mode full --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
## 断点续跑
- Step1 成功后会写入 `topAudiences`。
- Step2 成功后会写入 `topPathways`。
- Step3/Step4 会写入两类脆弱点。
- Step5/Step6 会写入两类策略。
- 各步骤会同步前端 `task/task-action.step`;需要禁用时每条命令追加 `--no-step-status`。
- 策略步骤会同步写入 channel、narrate、message、策略与受众/传播节点关系表,保证原前端策略表格/编排页可继续读取。
- 每个步骤完成都会输出 `STEP_FEEDBACK`,总控必须逐步转述给用户。
- 每次操作和接口调用都会输出 `OP_FEEDBACK`,总控必须逐条转述当前操作。
- 每次错误都会输出 `ERROR_FEEDBACK`,总控必须立即报告错误细节。
- 每次 JSON 彻底解析失败都会输出 `JSON_PARSE_REVIEW`,总控必须复核响应片段并判断是否重试当前 step。
- 每个步骤里的流式模型接口第一次空响应、返回 `y模型没有响应`/`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 某一步失败后,修正问题再重跑同一个 `--mode stepN`。
## 汇报规则
执行过程中按 `OP_FEEDBACK` 告诉用户当前操作和接口;按 `ERROR_FEEDBACK` 立即报告错误;每个 step 结束后再根据 `STEP_FEEDBACK` 回复用户一次。全部步骤结束后,读取 `xdfx-flow-summary.md` 和 `xdfx-flow-state.json.errors` 再做最终汇总。只要存在错误,就明确说“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
File diff suppressed because it is too large. Load diff
+268
View File
@@ -0,0 +1,268 @@
# xdfx 分步技能使用说明
## 适用场景
使用分步技能时,总控技能 `xdfx-split-controller` 负责告诉智能体按顺序调用 6 个步骤技能。适合弱模型、长流程不稳定、需要断点续跑或单独重跑某一步的场景。
## 技能目录
```text
skills-data/xdfx-split-controller/
skills-data/xdfx-step1-audience/
skills-data/xdfx-step2-pathway/
skills-data/xdfx-step3-audience-vulns/
skills-data/xdfx-step4-pathway-vulns/
skills-data/xdfx-step5-audience-strategy/
skills-data/xdfx-step6-pathway-strategy/
```
每个技能目录下都有同一份执行器:
```text
scripts/xdfx_flow.py
```
使用时不要让智能体手写接口调用,必须直接执行脚本。
## 输入文件
准备统一的 `xdfx-input.json`:
```json
{
"api_base": "https://ch1.b.uat.4.cn",
"taskId": "真实任务id",
"actionId": "真实xdfx行动id",
"mubiao": "完整行动目标",
"direction": "U方向",
"platform": "fb"
}
```
所有步骤共用同一个状态文件:
```text
xdfx-flow-state.json
```
默认会自动处理登录态:缺少 `Authorization` 或接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`,拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;需要严格校验证书时追加 `--verify-ssl`。已有 token 时可追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`。
每一步命令结束都会输出一行:
```text
STEP_FEEDBACK {...}
```
总控智能体必须在每一步结束后立即把这行反馈转述给用户,再执行下一步。
每次执行具体操作或调用接口前,脚本会输出:
```text
OP_FEEDBACK {...}
```
总控智能体必须把 `operation`、`method` 和 `url` 转述给用户。
如果报错,脚本会立即输出:
```text
ERROR_FEEDBACK {...}
```
总控智能体必须立刻告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果模型 JSON 经过自动提取和修复仍无法解析,脚本会输出:
```text
JSON_PARSE_REVIEW {...}
```
总控智能体必须查看 `responseExcerpt`,判断是否能识别真正 JSON;无法确认时重跑当前 step 或重新调用对应模型接口。
每一步中的流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,脚本会自动再调用一次;如果第二次仍失败,才写入 `errors` 并在该步骤反馈里体现为 `partial`。
## 推荐执行顺序
总控技能按下面顺序执行:
```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
```
如果某一步失败,修复接口、鉴权或输入后,只重跑对应 `stepN` 即可。
## 每步反馈格式
脚本会输出三类反馈行:
- `OP_FEEDBACK`:当前正在做什么、调用哪个接口。
- `ERROR_FEEDBACK`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
- `JSON_PARSE_REVIEW`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
- `STEP_FEEDBACK`:当前步骤最终状态和累计入库数量。
`STEP_FEEDBACK` 中包含:
- `step`:步骤编号。
- `label`:步骤名称。
- `status`:`ok`、`partial` 或 `skipped`。
- `counts`:当前累计入库数量。
- `newErrors`:本步骤新增错误数。
- `totalErrors`:累计错误数。
总控反馈用户时建议使用这种格式:
```text
Step2 关键传播节点分析完成:当前已入库传播节点 X 个,本步骤新增错误 Y 个。
```
如果 `status=partial`,说明该步骤部分完成,并继续执行后续可执行步骤。
如果 `status=skipped`,说明跳过原因。
## 各步骤职责
### Step1:受众目标分析
技能:`xdfx-step1-audience`
动作:
- 调用受众分析模型。
- 抽取结构化受众。
- 选择最优 3 个受众。
- 保存受众与受众账号。
- 同步前端 `step-analysis-audience`、`step-analysis-keypoint` 状态。
输出写入:
```text
topAudiences
```
### Step2:关键传播节点分析
技能:`xdfx-step2-pathway`
动作:
- 基于 Step1 的受众逐个分析关键传播节点。
- 跨受众去重。
- 选择全局最优 3 个节点。
- 保存传播节点。
- 保存受众与传播节点关系。
- 同步前端 `step-analysis-keypoint`、`step-analysis-weakpoint` 状态。
输出写入:
```text
topPathways
```
### Step3:受众脆弱点分析
技能:`xdfx-step3-audience-vulns`
动作:
- 基于 `topAudiences` 分析受众脆弱点。
- 每个受众最多保存 3 个脆弱点。
- 同步前端 `step-analysis-weakpoint` 状态。
输出写入:
```text
audienceVulns
```
### Step4:传播节点脆弱点分析
技能:`xdfx-step4-pathway-vulns`
动作:
- 基于 `topPathways` 分析传播节点脆弱点。
- 每个节点最多保存 3 个脆弱点。
- 同步前端 `step-analysis-weakpoint` 状态。
输出写入:
```text
pathwayVulns
```
### Step5:受众策略分析
技能:`xdfx-step5-audience-strategy`
动作:
- 基于 `topAudiences` 与 `audienceVulns` 生成策略。
- 保存策略主表。
- 同步写入策略 channel、narrate、message 和策略-受众关系。
- 同步前端 `step-analysis-strategy`、`step-analysis-strategy-gantt` 状态。
输出写入:
```text
audienceStrategies
```
### Step6:传播节点策略分析
技能:`xdfx-step6-pathway-strategy`
动作:
- 基于 `topPathways` 与 `pathwayVulns` 生成策略。
- 保存策略主表。
- 同步写入策略 channel、narrate、message 和策略-传播节点关系。
- 同步前端 `step-analysis-strategy`、`step-analysis-strategy-gantt` 状态。
输出写入:
```text
pathwayStrategies
```
## 断点续跑
每一步都会读写 `xdfx-flow-state.json`。
如果 Step3 失败,只需要重新执行:
```bash
python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
如果想重新生成某一步,需要先清理 `xdfx-flow-state.json` 中对应字段,例如:
- 重新跑受众:清理 `topAudiences` 和后续所有字段。
- 重新跑传播节点:清理 `topPathways`、`pathwayVulns`、`pathwayStrategies`。
- 重新跑策略:清理 `audienceStrategies` 或 `pathwayStrategies`。
## 错误汇报
每一步执行结束后都要查看:
```text
xdfx-flow-summary.md
xdfx-flow-state.json
```
优先依据本步骤的 `STEP_FEEDBACK` 给即时反馈;最终汇总时再读取完整 summary 和 errors。
如果 `errors` 非空,必须告诉用户:
- 失败阶段。
- 接口 URL。
- HTTP 状态码。
- 请求摘要。
- 响应正文摘要。
- 哪些数据已成功入库,哪些数据未完成。
只要存在错误,就说“部分完成”,不要说“全部完成”。
+51
View File
@@ -0,0 +1,51 @@
---
name: xdfx-step1-audience
description: xdfx 拆分流程 step1,分析受众目标,选择最优3个受众,并保存受众与受众账号。
---
# Step1 受众分析与入库
不要手写接口调用,直接运行:
```bash
python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
`xdfx-input.json` 必须包含:
```json
{
"api_base": "https://ch1.b.uat.4.cn",
"taskId": "真实任务id",
"actionId": "真实xdfx行动id",
"mubiao": "完整行动目标",
"direction": "U方向",
"platform": "fb"
}
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 调用受众分析模型接口。
- 自动解析流式 answer 与候选账号。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 抽取结构化受众。
- 选择最优 3 个受众。
- 保存受众目标。
- 保存受众目标账号。
- 同步前端步骤状态 `step-analysis-audience` 与 `step-analysis-keypoint`。
- 把 `designId`、账号、错误明细写入 `xdfx-flow-state.json`。
执行后检查 `xdfx-flow-summary.md`。如果 `errors` 非空,向用户报告失败接口、状态码、响应正文摘要。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step1 的完成状态、当前受众入库数量和新增错误数,再决定是否继续下一步。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众 JSON;无法确认时重跑 Step1。
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
---
name: xdfx-step2-pathway
description: xdfx 拆分流程 step2,基于受众分析关键传播节点,选择最优3个节点,保存节点并建立受众关系。
---
# Step2 关键传播节点分析与入库
前置条件:`xdfx-flow-state.json` 中已有 Step1 写入的 `topAudiences[*].designId`。
不要手写接口调用,直接运行:
```bash
python scripts/xdfx_flow.py --mode step2 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 对已入库受众逐个分析关键传播节点。
- 自动解析候选账号和传播节点 JSON。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 跨受众合并去重,选择全局最优 3 个传播节点。
- 保存传播节点。
- 保存传播节点与受众关系。
- 同步前端步骤状态 `step-analysis-keypoint` 与 `step-analysis-weakpoint`。
- 把 `pathwayAccountId`、关联 `designIds`、错误明细写入 state。
关系保存失败不阻断后续流程,但必须报告给用户。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step2 的完成状态、当前传播节点入库数量和新增错误数,再决定是否继续下一步。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点 JSON;无法确认时重跑 Step2。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,38 @@
---
name: xdfx-step3-audience-vulns
description: xdfx 拆分流程 step3,为已入库受众抽取并保存最多3个脆弱点。
---
# Step3 受众脆弱点抽取与入库
前置条件:`xdfx-flow-state.json` 中已有 Step1 写入的 `topAudiences[*].designId`。
不要手写接口调用,直接运行:
```bash
python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 对每个已入库受众抽取最多 3 个脆弱点。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动修复和解析模型 JSON。
- 调用受众脆弱点保存接口。
- 只有保存成功并返回 `id` 的脆弱点才进入后续策略阶段。
- 同步前端步骤状态 `step-analysis-weakpoint`。
- 单个受众失败不影响其他受众。
完成后查看 `audienceVulns` 和 `errors`。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step3 的完成状态、当前受众脆弱点数量和新增错误数,再决定是否继续下一步。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众脆弱点 JSON;无法确认时重跑 Step3。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,38 @@
---
name: xdfx-step4-pathway-vulns
description: xdfx 拆分流程 step4,为已入库关键传播节点抽取并保存最多3个脆弱点。
---
# Step4 传播节点脆弱点抽取与入库
前置条件:`xdfx-flow-state.json` 中已有 Step2 写入的 `topPathways[*].pathwayAccountId`。
不要手写接口调用,直接运行:
```bash
python scripts/xdfx_flow.py --mode step4 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 对每个已入库关键传播节点抽取最多 3 个脆弱点。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动修复和解析模型 JSON。
- 调用传播节点脆弱点保存接口。
- 只有保存成功并返回 `id` 的脆弱点才进入后续策略阶段。
- 同步前端步骤状态 `step-analysis-weakpoint`。
- 单个节点失败不影响其他节点。
完成后查看 `pathwayVulns` 和 `errors`。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step4 的完成状态、当前传播节点脆弱点数量和新增错误数,再决定是否继续下一步。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点脆弱点 JSON;无法确认时重跑 Step4。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,39 @@
---
name: xdfx-step5-audience-strategy
description: xdfx 拆分流程 step5,基于受众目标与受众脆弱点生成策略并保存。
---
# Step5 受众目标 + 脆弱点策略生成与入库
前置条件:`xdfx-flow-state.json` 中已有 Step3 写入的 `audienceVulns`。
不要手写接口调用,直接运行:
```bash
python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 按受众读取已入库脆弱点。
- 调用策略生成模型接口。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。
- 调用策略保存接口。
- 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`。
- 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。
- 保存成功后写入 `audienceStrategies`。
如果某个受众没有可用脆弱点,脚本会跳过并在 `errors` 中记录。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step5 的完成状态、当前受众策略数量和新增错误数,再决定是否继续下一步。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众策略 JSON;无法确认时重跑 Step5。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,39 @@
---
name: xdfx-step6-pathway-strategy
description: xdfx 拆分流程 step6,基于关键传播节点与节点脆弱点生成策略并保存。
---
# Step6 传播节点 + 脆弱点策略生成与入库
前置条件:`xdfx-flow-state.json` 中已有 Step4 写入的 `pathwayVulns`。
不要手写接口调用,直接运行:
```bash
python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 按关键传播节点读取已入库脆弱点。
- 调用策略生成模型接口。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。
- 调用策略保存接口。
- 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`。
- 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。
- 保存成功后写入 `pathwayStrategies`。
如果某个传播节点没有可用脆弱点,脚本会跳过并在 `errors` 中记录。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step6 的完成状态、当前传播节点策略数量和新增错误数,再做最终汇总。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点策略 JSON;无法确认时重跑 Step6。
File diff suppressed because it is too large. Load diff
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more