Merge branch 'dhk-1' into 'master'

Dhk 1

See merge request knowledge-base-bj/deerflow-server!169
This commit is contained in:
董宏凯 committed 2026-07-03 08:37:06 +00:00
commit 195ef3ee40
30 files changed
+1222 -204

No files matched your search

+2 -2
View File
@@ -14,8 +14,8 @@ VITE_MOCK_SQ_REPORT=false
VITE_MOCK_COP_TASK_DETAIL=false VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001 VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api
# 轻应用「新建笔记」系列接口 # 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
@@ -260,37 +260,68 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
key={agent.id} key={agent.id}
href={href} href={href}
className={cn( className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors", "group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 transition-all duration-150",
isActive isActive
? "border-primary/40 bg-primary/5" ? isDark
: "border-transparent hover:border-border hover:bg-accent/50", ? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]"
: "border-primary/50 bg-primary/10 shadow-sm"
: isDark
? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60"
: "border-transparent hover:border-border hover:bg-accent/50",
)} )}
> >
<span <span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm" className={cn(
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
isDark &&
isActive &&
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
)}
style={{ style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`, backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
}} }}
> >
<Bot className="size-[18px]" /> <Bot className="size-[19px]" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span <span
className={cn( className={cn(
"block truncate text-[13px] font-semibold", "block truncate text-[16px] leading-5 font-bold",
isActive ? "text-primary" : "text-foreground", isActive
? isDark
? "text-white"
: "text-primary"
: isDark
? "text-white/95"
: "text-foreground",
)} )}
> >
{desensitize(agent.name)} {desensitize(agent.name)}
</span> </span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground"> <span
className={cn(
"mt-1 block truncate text-[13px] leading-[1.35]",
isDark
? isActive
? "text-[#d6eaff]"
: "text-[#9ebfe2]"
: "text-muted-foreground",
)}
>
{agent.description {agent.description
? desensitize(agent.description) ? desensitize(agent.description)
: "智能体助手"} : "智能体助手"}
</span> </span>
</span> </span>
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span
className={cn(
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
isDark
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
: "bg-primary",
)}
/>
)} )}
</Link> </Link>
); );
@@ -219,33 +219,64 @@ export function AgentModuleSidebar({
} }
}} }}
className={cn( className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors", "group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 transition-all duration-150",
isActive isActive
? "border-primary/40 bg-primary/5" ? isDark
: "border-transparent hover:border-border hover:bg-accent/50", ? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]"
: "border-primary/50 bg-primary/10 shadow-sm"
: isDark
? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60"
: "border-transparent hover:border-border hover:bg-accent/50",
)} )}
> >
<span <span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm" className={cn(
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
isDark &&
isActive &&
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
)}
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }} style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
> >
<Bot className="size-[18px]" /> <Bot className="size-[19px]" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span <span
className={cn( className={cn(
"block truncate text-[13px] font-semibold", "block truncate text-[16px] leading-5 font-bold",
isActive ? "text-primary" : "text-foreground", isActive
? isDark
? "text-white"
: "text-primary"
: isDark
? "text-white/95"
: "text-foreground",
)} )}
> >
{desensitize(agent.name)} {desensitize(agent.name)}
</span> </span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground"> <span
className={cn(
"mt-1 block truncate text-[13px] leading-[1.35]",
isDark
? isActive
? "text-[#d6eaff]"
: "text-[#9ebfe2]"
: "text-muted-foreground",
)}
>
{agent.description ? desensitize(agent.description) : "智能体助手"} {agent.description ? desensitize(agent.description) : "智能体助手"}
</span> </span>
</span> </span>
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span
className={cn(
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
isDark
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
: "bg-primary",
)}
/>
)} )}
</Link> </Link>
); );
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react"; import { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -14,8 +14,9 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { useLayoutMode } from "@/core/system-settings/hooks"; import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
/** /**
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用, * 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
@@ -39,6 +40,43 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]" ? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
: "text-muted-foreground hover:bg-muted hover:text-foreground"; : "text-muted-foreground hover:bg-muted hover:text-foreground";
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
const { data: appearance } = useAppearanceSettings();
const buttons = (appearance?.compact_shortcut_buttons ?? [])
.filter((button) => button.enabled && button.label.trim() && button.url.trim())
.sort((a, b) => a.sort_order - b.sort_order);
if (buttons.length === 0) return null;
return (
<>
{buttons.map((button) => (
<Button
key={button.id}
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 max-w-[168px] shrink-0 gap-1.5 rounded-sm px-2 text-xs font-medium",
triggerToneClass(tone),
)}
title={button.label}
onClick={() => {
const url = buildLightAppUrl({
url: button.url,
routeParams: button.route_params.map((param) => ({ ...param })),
});
window.open(url, "_blank", "noopener,noreferrer");
}}
>
<ExternalLink className="size-4 shrink-0" />
<span className="truncate">{button.label}</span>
</Button>
))}
</>
);
}
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) { export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -349,33 +349,64 @@ export function GeneralQaSidebar() {
type="button" type="button"
onClick={() => handleSelectAgent(agent.id)} onClick={() => handleSelectAgent(agent.id)}
className={cn( className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 text-left transition-colors", "group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150",
isActive isActive
? "border-primary/40 bg-primary/5" ? isDark
: "border-transparent hover:border-border hover:bg-accent/50", ? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]"
: "border-primary/50 bg-primary/10 shadow-sm"
: isDark
? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60"
: "border-transparent hover:border-border hover:bg-accent/50",
)} )}
> >
<span <span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm" className={cn(
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
isDark &&
isActive &&
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
)}
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }} style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
> >
<Bot className="size-[18px]" /> <Bot className="size-[19px]" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span <span
className={cn( className={cn(
"block truncate text-[13px] font-semibold", "block truncate text-[16px] leading-5 font-bold",
isActive ? "text-primary" : "text-foreground", isActive
? isDark
? "text-white"
: "text-primary"
: isDark
? "text-white/95"
: "text-foreground",
)} )}
> >
{desensitize(agent.name)} {desensitize(agent.name)}
</span> </span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground"> <span
className={cn(
"mt-1 block truncate text-[13px] leading-[1.35]",
isDark
? isActive
? "text-[#d6eaff]"
: "text-[#9ebfe2]"
: "text-muted-foreground",
)}
>
{agent.description ? desensitize(agent.description) : "智能体助手"} {agent.description ? desensitize(agent.description) : "智能体助手"}
</span> </span>
</span> </span>
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span
className={cn(
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
isDark
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
: "bg-primary",
)}
/>
)} )}
</button> </button>
); );
@@ -1,16 +1,30 @@
"use client"; "use client";
import { DropletIcon, LayoutGridIcon, LayoutIcon, ListIcon, MoonIcon, PanelLeftIcon, SunIcon } from "lucide-react"; import {
import { useMemo, useState, type ComponentType, type SVGProps } from "react"; ArrowDown,
ArrowUp,
DropletIcon,
ExternalLink,
LayoutGridIcon,
LayoutIcon,
ListIcon,
MoonIcon,
PanelLeftIcon,
Plus,
SunIcon,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { FONT_SCALE_OPTIONS } from "@/components/font-scale"; import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Input } from "@/components/ui/input";
Select, import {
Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
@@ -25,14 +39,24 @@ import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-sessio
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n"; import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { import {
useAppearanceSettings, useAppearanceSettings,
useCitationDisplaySettings, useCitationDisplaySettings,
useLayoutMode, useLayoutMode,
useUpdateAppearanceSettings, useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings, useUpdateCitationDisplaySettings,
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils"; import type {
CompactShortcutButton,
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
parseThemeMap,
stringifyThemeMap,
} from "@/strategy-components/lib/light-app";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage"; import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
@@ -41,10 +65,101 @@ import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
const languageOptions: { value: Locale; label: string }[] = [ const languageOptions: { value: Locale; label: string }[] = [
{ value: "en-US", label: enUS.locale.localName }, { value: "en-US", label: enUS.locale.localName },
{ value: "zh-CN", label: zhCN.locale.localName }, { value: "zh-CN", label: zhCN.locale.localName },
]; ];
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function newShortcutParam(): CompactShortcutRouteParam {
return {
key: "access_token",
paramType: "login",
source: "access_token",
customValue: "",
themeValue: DEFAULT_SHORTCUT_THEME_MAP,
};
}
function newShortcutButton(): CompactShortcutButton {
return {
id: makeShortcutId(),
label: "快捷入口",
url: "",
route_params: [newShortcutParam()],
enabled: true,
sort_order: 0,
};
}
function normalizeShortcutDraft(
buttons: CompactShortcutButton[] | undefined,
): CompactShortcutButton[] {
const source = buttons?.length ? buttons : [newShortcutButton()];
return [...source]
.sort((a, b) => a.sort_order - b.sort_order)
.map((button, index) => ({
...button,
id: button.id || makeShortcutId(),
label: button.label || "快捷入口",
url: button.url || "",
route_params: (button.route_params?.length
? button.route_params
: [newShortcutParam()]
).map((param) => ({
key: param.key || "",
paramType: param.paramType || "login",
source: param.source || "access_token",
customValue: param.customValue ?? "",
themeValue: param.themeValue || DEFAULT_SHORTCUT_THEME_MAP,
})),
enabled: button.enabled ?? true,
sort_order: index,
}));
}
function cleanShortcutParams(
params: CompactShortcutRouteParam[],
): CompactShortcutRouteParam[] {
return params
.filter((param) => param.key.trim())
.map((param) => {
const base: CompactShortcutRouteParam = {
key: param.key.trim(),
paramType: param.paramType,
};
if (param.paramType === "theme") {
base.themeValue = stringifyThemeMap(parseThemeMap(param.themeValue));
return base;
}
base.source = param.source || "access_token";
if (base.source === "other") {
base.customValue = (param.customValue ?? "").trim();
}
return base;
});
}
function cleanShortcutButtons(
buttons: CompactShortcutButton[],
): CompactShortcutButton[] {
return buttons.map((button, index) => ({
id: button.id || makeShortcutId(),
label: button.label.trim() || "快捷入口",
url: button.url.trim(),
route_params: cleanShortcutParams(button.route_params),
enabled: button.enabled,
sort_order: index,
}));
}
export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const { t, locale, changeLocale } = useI18n(); const { t, locale, changeLocale } = useI18n();
@@ -52,10 +167,11 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const currentTheme = theme; const currentTheme = theme;
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: appearance } = useAppearanceSettings(); const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings(); const updateAppearance = useUpdateAppearanceSettings();
const { data: citationDisplay } = useCitationDisplaySettings(); const [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]);
const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings(); const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light"; const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list"; const globalSkillView = appearance?.default_skill_view ?? "list";
@@ -63,8 +179,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const globalLayoutMode = appearance?.default_layout_mode ?? "full"; const globalLayoutMode = appearance?.default_layout_mode ?? "full";
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。 // 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode(); const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords = const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
useEffect(() => {
setShortcutDraft(
normalizeShortcutDraft(appearance?.compact_shortcut_buttons),
);
}, [appearance?.compact_shortcut_buttons]);
// 独立登录会话(本标签页):开关切换鉴权存储 localStorage ↔ sessionStorage(见 scoped-storage)。 // 独立登录会话(本标签页):开关切换鉴权存储 localStorage ↔ sessionStorage(见 scoped-storage)。
const [isolatedSession, setIsolatedSessionState] = useState(() => isIsolatedSession()); const [isolatedSession, setIsolatedSessionState] = useState(() => isIsolatedSession());
@@ -115,7 +237,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
} }
}; };
const handleSetDefaultView = async ( const handleSetDefaultView = async (
kind: "skill" | "agent", kind: "skill" | "agent",
value: "card" | "list", value: "card" | "list",
) => { ) => {
@@ -134,7 +256,103 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败"); toast.error(e instanceof Error ? e.message : "保存失败");
} }
}; };
const patchShortcut = (
id: string,
patch: Partial<CompactShortcutButton>,
) => {
setShortcutDraft((items) =>
items.map((item) => (item.id === id ? { ...item, ...patch } : item)),
);
};
const patchShortcutParam = (
id: string,
index: number,
patch: Partial<CompactShortcutRouteParam>,
) => {
setShortcutDraft((items) =>
items.map((item) =>
item.id === id
? {
...item,
route_params: item.route_params.map((param, paramIndex) =>
paramIndex === index ? { ...param, ...patch } : param,
),
}
: item,
),
);
};
const moveShortcut = (id: string, direction: -1 | 1) => {
setShortcutDraft((items) => {
const index = items.findIndex((item) => item.id === id);
const target = index + direction;
if (index < 0 || target < 0 || target >= items.length) return items;
const next = [...items];
[next[index], next[target]] = [next[target]!, next[index]!];
return next.map((item, order) => ({ ...item, sort_order: order }));
});
};
const addShortcutParam = (id: string) => {
setShortcutDraft((items) =>
items.map((item) =>
item.id === id
? {
...item,
route_params: [...item.route_params, newShortcutParam()],
}
: item,
),
);
};
const removeShortcutParam = (id: string, index: number) => {
setShortcutDraft((items) =>
items.map((item) =>
item.id === id
? {
...item,
route_params: item.route_params.filter((_, paramIndex) => paramIndex !== index),
}
: item,
),
);
};
const handleSaveShortcuts = async () => {
for (const item of shortcutDraft) {
if (item.enabled && !item.url.trim()) {
toast.error(`请填写「${item.label.trim() || "快捷入口"}」的跳转链接,或先关闭该按钮`);
return;
}
for (const param of item.route_params) {
if (!param.key.trim()) {
toast.error("路由参数键名不可为空");
return;
}
if (
param.paramType === "login" &&
param.source === "other" &&
!param.customValue?.trim()
) {
toast.error("登录参数自定义值不可为空");
return;
}
}
}
try {
await updateAppearance.mutateAsync({
compact_shortcut_buttons: cleanShortcutButtons(shortcutDraft),
});
toast.success("顶部快捷按钮已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const themeOptions = useMemo( const themeOptions = useMemo(
() => [ () => [
@@ -224,11 +442,11 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
</div> </div>
</SettingsSection> </SettingsSection>
{isAdmin && ( {isAdmin && (
<> <>
<Separator /> <Separator />
<SettingsSection <SettingsSection
title="系统默认界面模式(管理员)" title="系统默认界面模式(管理员)"
description="给所有还没有手动切换过界面模式的用户的默认;已经自己切换过的用户保留其个人选择不受影响。" description="给所有还没有手动切换过界面模式的用户的默认;已经自己切换过的用户保留其个人选择不受影响。"
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -257,13 +475,47 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
{globalLayoutMode === "full" ? "全量模式" : "简洁模式"} {globalLayoutMode === "full" ? "全量模式" : "简洁模式"}
</p> </p>
</div> </div>
</SettingsSection> </SettingsSection>
</> </>
)} )}
{isAdmin && ( {isAdmin && (
<> <>
<Separator /> <Separator />
<SettingsSection
title="顶部快捷按钮(管理员)"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
>
<CompactShortcutSettings
buttons={shortcutDraft}
saving={updateAppearance.isPending}
onAdd={() =>
setShortcutDraft((items) => [
...items,
{ ...newShortcutButton(), sort_order: items.length },
])
}
onPatch={patchShortcut}
onMove={moveShortcut}
onRemove={(id) =>
setShortcutDraft((items) =>
items
.filter((item) => item.id !== id)
.map((item, index) => ({ ...item, sort_order: index })),
)
}
onAddParam={addShortcutParam}
onPatchParam={patchShortcutParam}
onRemoveParam={removeShortcutParam}
onSave={handleSaveShortcuts}
/>
</SettingsSection>
</>
)}
{isAdmin && (
<>
<Separator />
<SettingsSection <SettingsSection
title="系统默认主题(管理员)" title="系统默认主题(管理员)"
description="给所有还没有手动切换过主题的用户的默认主题;已经自己切换过的用户保留其个人设置不受影响。" description="给所有还没有手动切换过主题的用户的默认主题;已经自己切换过的用户保留其个人设置不受影响。"
@@ -515,12 +767,304 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
</> </>
)} )}
</div> </div>
); );
} }
function ThemePreviewCard({ function CompactShortcutSettings({
icon: Icon, buttons,
label, saving,
onAdd,
onPatch,
onMove,
onRemove,
onAddParam,
onPatchParam,
onRemoveParam,
onSave,
}: {
buttons: CompactShortcutButton[];
saving: boolean;
onAdd: () => void;
onPatch: (id: string, patch: Partial<CompactShortcutButton>) => void;
onMove: (id: string, direction: -1 | 1) => void;
onRemove: (id: string) => void;
onAddParam: (id: string) => void;
onPatchParam: (
id: string,
index: number,
patch: Partial<CompactShortcutRouteParam>,
) => void;
onRemoveParam: (id: string, index: number) => void;
onSave: () => void;
}) {
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
<Plus className="mr-1.5 size-4" />
添加按钮
</Button>
<Button
type="button"
size="sm"
onClick={onSave}
disabled={saving}
>
保存按钮配置
</Button>
</div>
</div>
{buttons.length === 0 ? (
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm">
暂无按钮,点击“添加按钮”创建一个。
</div>
) : (
<div className="space-y-3">
{buttons.map((button, index) => (
<div key={button.id} className="space-y-4 rounded-lg border p-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start">
<div className="flex items-center gap-2 pt-6">
<Switch
checked={button.enabled}
onCheckedChange={(checked) =>
onPatch(button.id, { enabled: checked })
}
aria-label="启用快捷按钮"
/>
<span className="text-muted-foreground text-xs">
{button.enabled ? "启用" : "停用"}
</span>
</div>
<div className="grid min-w-0 flex-1 gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
<div className="space-y-1.5">
<div className="text-xs font-medium">按钮名称</div>
<Input
value={button.label}
onChange={(event) =>
onPatch(button.id, { label: event.target.value })
}
placeholder="如:业务系统"
/>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium">跳转链接</div>
<Input
value={button.url}
onChange={(event) =>
onPatch(button.id, { url: event.target.value })
}
placeholder="https://example.com/page"
className="font-mono"
/>
</div>
</div>
<div className="flex shrink-0 items-center gap-1 pt-6">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onMove(button.id, -1)}
disabled={index === 0}
title="上移"
>
<ArrowUp className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onMove(button.id, 1)}
disabled={index === buttons.length - 1}
title="下移"
>
<ArrowDown className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:text-destructive"
onClick={() => onRemove(button.id)}
title="删除"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm font-medium">
<ExternalLink className="size-4" />
路由参数
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onAddParam(button.id)}
>
<Plus className="mr-1.5 size-4" />
添加参数
</Button>
</div>
{button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。
</div>
) : (
<div className="space-y-2">
{button.route_params.map((param, paramIndex) => {
const themeMap =
param.paramType === "theme"
? parseThemeMap(param.themeValue)
: {};
return (
<div
key={`${button.id}-${paramIndex}`}
className="space-y-2 rounded-md border bg-background p-2"
>
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
<Input
value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
<Select
value={param.paramType}
onValueChange={(value) =>
onPatchParam(button.id, paramIndex, {
paramType: value as "login" | "theme",
source:
value === "login"
? param.source || "access_token"
: undefined,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem>
</SelectContent>
</Select>
{param.paramType === "login" ? (
<div className="flex min-w-0 gap-2">
<Select
value={param.source || "access_token"}
onValueChange={(value) =>
onPatchParam(button.id, paramIndex, {
source: value,
})
}
>
<SelectTrigger className="min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOGIN_SOURCE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{param.source === "other" && (
<Input
value={param.customValue ?? ""}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
customValue: event.target.value,
})
}
placeholder="自定义值"
className="w-32 text-xs"
/>
)}
</div>
) : (
<div className="text-muted-foreground flex items-center text-xs">
按当前主题取值
</div>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:text-destructive"
onClick={() =>
onRemoveParam(button.id, paramIndex)
}
title="删除参数"
>
<Trash2 className="size-4" />
</Button>
</div>
{param.paramType === "theme" && (
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
{APP_THEME_COLORS.map((color) => (
<label
key={color.key}
className="flex min-w-0 items-center gap-2"
>
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: color.dot }}
/>
<span className="text-muted-foreground shrink-0 text-xs">
{color.label}
</span>
<Input
value={themeMap[color.key] ?? color.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
themeValue: stringifyThemeMap({
...themeMap,
[color.key]: event.target.value,
}),
})
}
placeholder={color.key}
className="h-8 min-w-0 font-mono text-xs"
/>
</label>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
function ThemePreviewCard({
icon: Icon,
label,
description, description,
active, active,
mode, mode,
@@ -279,12 +279,31 @@ export async function updatePublishSquares(
export type ThemeMode = "light" | "dark" | "lightblue"; export type ThemeMode = "light" | "dark" | "lightblue";
export type ViewMode = "card" | "list"; export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact"; export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme";
export interface CompactShortcutRouteParam {
key: string;
paramType: CompactShortcutParamType;
source?: string | null;
customValue?: string | null;
themeValue?: string | null;
}
export interface CompactShortcutButton {
id: string;
label: string;
url: string;
route_params: CompactShortcutRouteParam[];
enabled: boolean;
sort_order: number;
}
export interface AppearanceSettings { export interface AppearanceSettings {
default_theme: ThemeMode; default_theme: ThemeMode;
default_skill_view: ViewMode; default_skill_view: ViewMode;
default_agent_view: ViewMode; default_agent_view: ViewMode;
default_layout_mode: LayoutMode; default_layout_mode: LayoutMode;
compact_shortcut_buttons: CompactShortcutButton[];
} }
export interface AppearanceUpdate { export interface AppearanceUpdate {
@@ -292,6 +311,7 @@ export interface AppearanceUpdate {
default_skill_view?: ViewMode; default_skill_view?: ViewMode;
default_agent_view?: ViewMode; default_agent_view?: ViewMode;
default_layout_mode?: LayoutMode; default_layout_mode?: LayoutMode;
compact_shortcut_buttons?: CompactShortcutButton[];
} }
export async function loadAppearanceSettings(): Promise<AppearanceSettings> { export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
@@ -450,7 +450,7 @@ function AIWritingBody({
{showSidebar && ( {showSidebar && (
<div <div
className={cn( className={cn(
'relative h-full shrink-0 overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)] transition-[width] duration-300 ease-in-out', 'relative h-full shrink-0 overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)] transition-[width] duration-300 ease-in-out dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:text-[#c5cfe0] dark:shadow-none dark:backdrop-blur-md',
sidebarCollapsed ? 'w-11' : 'w-[320px]', sidebarCollapsed ? 'w-11' : 'w-[320px]',
)} )}
> >
@@ -465,11 +465,11 @@ function AIWritingBody({
onClick={() => setSidebarCollapsed(false)} onClick={() => setSidebarCollapsed(false)}
title="展开侧栏" title="展开侧栏"
aria-label="展开侧栏" aria-label="展开侧栏"
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700" className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]"
> >
<PanelLeftOpen className="size-4" /> <PanelLeftOpen className="size-4" />
</button> </button>
<History className="size-4 text-slate-300" /> <History className="size-4 text-slate-300 dark:text-[#6e88b0]" />
</div> </div>
<aside <aside
className={cn( className={cn(
@@ -477,8 +477,8 @@ function AIWritingBody({
sidebarCollapsed ? 'pointer-events-none -translate-x-4 opacity-0' : 'translate-x-0 opacity-100', 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="flex shrink-0 items-center gap-2 border-b border-slate-100 px-3 py-2 dark:border-[#2b8ffe]/20">
<div className="grid flex-1 grid-cols-2 rounded-lg bg-slate-100 p-1"> <div className="grid flex-1 grid-cols-2 rounded-lg bg-slate-100 p-1 dark:bg-white/10">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -489,8 +489,8 @@ function AIWritingBody({
className={cn( className={cn(
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors', 'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
sidebarTab === 'agents' sidebarTab === 'agents'
? 'bg-white text-slate-900 shadow-sm' ? 'bg-white text-slate-900 shadow-sm dark:bg-[#122347]/90 dark:text-[#e8edf5] dark:shadow-none'
: 'text-slate-500 hover:text-slate-800', : 'text-slate-500 hover:text-slate-800 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]',
)} )}
> >
课题研究 课题研究
@@ -505,8 +505,8 @@ function AIWritingBody({
className={cn( className={cn(
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors', 'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
sidebarTab === 'research' sidebarTab === 'research'
? 'bg-white text-slate-900 shadow-sm' ? 'bg-white text-slate-900 shadow-sm dark:bg-[#122347]/90 dark:text-[#e8edf5] dark:shadow-none'
: 'text-slate-500 hover:text-slate-800', : 'text-slate-500 hover:text-slate-800 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]',
)} )}
> >
ai写作 ai写作
@@ -517,7 +517,7 @@ function AIWritingBody({
onClick={() => setSidebarCollapsed(true)} onClick={() => setSidebarCollapsed(true)}
title="收起侧栏" title="收起侧栏"
aria-label="收起侧栏" aria-label="收起侧栏"
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700" className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]"
> >
<PanelLeftClose className="size-4" /> <PanelLeftClose className="size-4" />
</button> </button>
@@ -594,7 +594,7 @@ function AIWritingBody({
</div> </div>
)} )}
{agentsActive && ( {agentsActive && (
<div className="absolute inset-0 bg-background"> <div className="workspace-chat-bg absolute inset-0">
<AIWritingAgentRoutes <AIWritingAgentRoutes
base={routeBase} base={routeBase}
compactAppearanceActions={compactAppearanceActions} compactAppearanceActions={compactAppearanceActions}
+2
View File
@@ -27,6 +27,7 @@ import { QuestionNavigator } from "@/components/workspace/question-navigator";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
import { import {
CompactShortcutButtons,
LayoutModeSwitcher, LayoutModeSwitcher,
ThemeSwitcher, ThemeSwitcher,
} from "@/components/workspace/appearance-controls"; } from "@/components/workspace/appearance-controls";
@@ -508,6 +509,7 @@ export default function ChatPage() {
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */} 与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
{!compactChrome && ( {!compactChrome && (
<> <>
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" /> <ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" /> <LayoutModeSwitcher tone="content" />
<div <div
+2
View File
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs"; import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import { import {
CompactShortcutButtons,
LayoutModeSwitcher, LayoutModeSwitcher,
ThemeSwitcher, ThemeSwitcher,
} from "@/components/workspace/appearance-controls"; } from "@/components/workspace/appearance-controls";
@@ -183,6 +184,7 @@ export default function ChatsPage({
rightActions={ rightActions={
compactChrome ? ( compactChrome ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" /> <ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" /> <LayoutModeSwitcher tone="content" />
</div> </div>
@@ -44,6 +44,7 @@ import {
WorkspaceHeader, WorkspaceHeader,
} from "@/components/workspace/workspace-container"; } from "@/components/workspace/workspace-container";
import { import {
CompactShortcutButtons,
LayoutModeSwitcher, LayoutModeSwitcher,
ThemeSwitcher, ThemeSwitcher,
} from "@/components/workspace/appearance-controls"; } from "@/components/workspace/appearance-controls";
@@ -502,6 +503,7 @@ export default function ScheduledTasksPage({
rightActions={ rightActions={
compactChrome ? ( compactChrome ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" /> <ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" /> <LayoutModeSwitcher tone="content" />
</div> </div>
+18 -1
View File
@@ -6,6 +6,7 @@ import { getStoredAuth } from '@/core/auth';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input'; import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
import { import {
CompactShortcutButtons,
LayoutModeSwitcher, LayoutModeSwitcher,
ThemeSwitcher, ThemeSwitcher,
} from '@/components/workspace/appearance-controls'; } from '@/components/workspace/appearance-controls';
@@ -91,6 +92,7 @@ function shouldShowCompactTopActions(pathname: string): boolean {
function CompactWorkspaceTopActions() { function CompactWorkspaceTopActions() {
return ( return (
<div className="z-[60] flex h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 bg-card/90 px-4 shadow-sm backdrop-blur-md"> <div className="z-[60] flex h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 bg-card/90 px-4 shadow-sm backdrop-blur-md">
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" /> <ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" /> <LayoutModeSwitcher tone="content" />
<div className="bg-border/70 mx-1 h-6 w-px shrink-0" aria-hidden /> <div className="bg-border/70 mx-1 h-6 w-px shrink-0" aria-hidden />
@@ -99,6 +101,16 @@ function CompactWorkspaceTopActions() {
); );
} }
function CompactAppearanceActions() {
return (
<>
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
</>
);
}
export function WorkspaceLayout({ export function WorkspaceLayout({
children, children,
pageSidebarCollapsed, pageSidebarCollapsed,
@@ -342,7 +354,12 @@ export default function WorkspaceRoutes({
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<ChatRuntime> <ChatRuntime>
<AgentChatPage showAgentList /> <AgentChatPage
showAgentList
compactAppearanceActions={
headerHidden ? <CompactAppearanceActions /> : undefined
}
/>
</ChatRuntime> </ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
} }
@@ -3067,7 +3067,7 @@ function RoundtableEntryRail({
{chains.length === 0 ? ( {chains.length === 0 ? (
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p> <p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
) : ( ) : (
<div className="max-h-[360px] overflow-y-auto pr-1 -mr-1 custom-scrollbar"> <div className="max-h-[292px] overflow-y-auto pr-1 -mr-1 custom-scrollbar">
<div className="flex flex-col gap-2.5 py-0.5"> <div className="flex flex-col gap-2.5 py-0.5">
{chains.map((chain) => { {chains.map((chain) => {
const selected = selectedChainId === chain.id; const selected = selectedChainId === chain.id;
@@ -3078,7 +3078,7 @@ function RoundtableEntryRail({
<div <div
key={chain.id} key={chain.id}
className={cn( className={cn(
"rounded-xl border transition-all duration-200", "min-h-[88px] rounded-xl border transition-all duration-200",
selected selected
? "border-primary/25 bg-primary/5 ring-1 ring-primary/20 shadow-sm" ? "border-primary/25 bg-primary/5 ring-1 ring-primary/20 shadow-sm"
: "border-[#ECE7E4] bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50", : "border-[#ECE7E4] bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50",
@@ -4,7 +4,11 @@ import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import "@/components/page-sdebar.css"; import "@/components/page-sdebar.css";
import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls"; import {
CompactShortcutButtons,
LayoutModeSwitcher,
ThemeSwitcher,
} from "@/components/workspace/appearance-controls";
import { NotificationDialog } from "@/components/workspace/notification-dialog"; import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth, getAccountDisplayName } from "@/core/auth"; import { getStoredAuth, getAccountDisplayName } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
@@ -124,6 +128,7 @@ const Header = ({ collapsed }: HeaderProps) => {
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用 // 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
const actionCluster = ( const actionCluster = (
<> <>
<CompactShortcutButtons tone={isDark ? "banner-dark" : "banner-light"} />
<ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} /> <ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} />
<LayoutModeSwitcher <LayoutModeSwitcher
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from typing import Literal from typing import Literal
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -13,6 +14,8 @@ from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_optional_user_from_request from app.gateway.deps import get_optional_user_from_request
from deerflow.config import get_app_config from deerflow.config import get_app_config
from deerflow.config.system_settings import ( from deerflow.config.system_settings import (
AppearanceRouteParam,
CompactShortcutButton,
LeaderboardSettings, LeaderboardSettings,
ModelConcurrencySettings, ModelConcurrencySettings,
PluginDomainAgentMapping, PluginDomainAgentMapping,
@@ -537,6 +540,7 @@ class AppearanceResponse(BaseModel):
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list") default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list") default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)") default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)")
compact_shortcut_buttons: list[CompactShortcutButton] = Field(default_factory=list, description="Shortcut buttons rendered in top bars")
class AppearanceUpdateRequest(BaseModel): class AppearanceUpdateRequest(BaseModel):
@@ -544,6 +548,61 @@ class AppearanceUpdateRequest(BaseModel):
default_skill_view: Literal["card", "list"] | None = Field(default=None) default_skill_view: Literal["card", "list"] | None = Field(default=None)
default_agent_view: Literal["card", "list"] | None = Field(default=None) default_agent_view: Literal["card", "list"] | None = Field(default=None)
default_layout_mode: Literal["full", "compact"] | None = Field(default=None) default_layout_mode: Literal["full", "compact"] | None = Field(default=None)
compact_shortcut_buttons: list[CompactShortcutButton] | None = Field(default=None)
def _normalize_compact_shortcut_buttons(
buttons: list[CompactShortcutButton] | None,
) -> list[CompactShortcutButton]:
normalized: list[CompactShortcutButton] = []
seen_ids: set[str] = set()
for index, item in enumerate(buttons or []):
label = (item.label or "").strip() or "快捷入口"
url = (item.url or "").strip()
row_id = (item.id or "").strip() or uuid4().hex
if row_id in seen_ids:
row_id = uuid4().hex
seen_ids.add(row_id)
route_params: list[AppearanceRouteParam] = []
for param in item.route_params or []:
key = (param.key or "").strip()
if not key:
continue
if param.paramType == "theme":
route_params.append(
AppearanceRouteParam(
key=key,
paramType="theme",
themeValue=(param.themeValue or "").strip()
or "red=red;blue=blue;dark-blue=dark-blue",
)
)
continue
source = (param.source or "access_token").strip() or "access_token"
route_params.append(
AppearanceRouteParam(
key=key,
paramType="login",
source=source,
customValue=(param.customValue or "").strip()
if source == "other"
else None,
)
)
normalized.append(
CompactShortcutButton(
id=row_id,
label=label,
url=url,
route_params=route_params,
enabled=bool(item.enabled),
sort_order=index,
)
)
return normalized
def _appearance_response(appearance) -> AppearanceResponse: def _appearance_response(appearance) -> AppearanceResponse:
@@ -552,6 +611,10 @@ def _appearance_response(appearance) -> AppearanceResponse:
default_skill_view=appearance.default_skill_view, default_skill_view=appearance.default_skill_view,
default_agent_view=appearance.default_agent_view, default_agent_view=appearance.default_agent_view,
default_layout_mode=appearance.default_layout_mode, default_layout_mode=appearance.default_layout_mode,
compact_shortcut_buttons=sorted(
appearance.compact_shortcut_buttons,
key=lambda item: item.sort_order,
),
) )
@@ -574,6 +637,10 @@ async def update_appearance_settings(
for key in ("default_theme", "default_skill_view", "default_agent_view", "default_layout_mode"): for key in ("default_theme", "default_skill_view", "default_agent_view", "default_layout_mode"):
if key in fields and fields[key] is not None: if key in fields and fields[key] is not None:
update[key] = fields[key] update[key] = fields[key]
if "compact_shortcut_buttons" in fields and body.compact_shortcut_buttons is not None:
update["compact_shortcut_buttons"] = _normalize_compact_shortcut_buttons(
body.compact_shortcut_buttons
)
if update: if update:
updated_appearance = current.appearance.model_copy(update=update) updated_appearance = current.appearance.model_copy(update=update)
saved = save_system_settings( saved = save_system_settings(
@@ -18,6 +18,7 @@ import logging
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -38,6 +39,60 @@ class PromptPrefixSettings(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
class AppearanceRouteParam(BaseModel):
"""Runtime query-param descriptor for top-bar shortcut buttons.
The frontend resolves login params from ``userInfo`` and theme params from
the current UI theme, matching the light-app center's link builder.
"""
key: str = Field(default="", description="Query parameter name appended to the shortcut URL")
paramType: Literal["login", "theme"] = Field(default="login", description="Where the parameter value comes from")
source: str | None = Field(default=None, description="login param source field in userInfo, or 'other'")
customValue: str | None = Field(default=None, description="Literal value when source is 'other'")
themeValue: str | None = Field(default=None, description="Theme-value mapping string, e.g. red=red;dark-blue=dark")
model_config = ConfigDict(extra="ignore")
def _default_shortcut_route_params() -> list[AppearanceRouteParam]:
return [
AppearanceRouteParam(
key="access_token",
paramType="login",
source="access_token",
customValue="",
themeValue="red=red;blue=blue;dark-blue=dark-blue",
)
]
class CompactShortcutButton(BaseModel):
"""Configurable shortcut shown in top bars before the theme switcher."""
id: str = Field(default_factory=lambda: uuid4().hex, description="Stable row id for editing")
label: str = Field(default="快捷入口", description="Button text shown next to the icon")
url: str = Field(default="", description="Target URL opened in a new tab")
route_params: list[AppearanceRouteParam] = Field(
default_factory=_default_shortcut_route_params,
description="Query parameters resolved at launch time and appended to url",
)
enabled: bool = Field(default=True, description="Whether this shortcut is rendered")
sort_order: int = Field(default=0, description="Display order in top bars")
model_config = ConfigDict(extra="ignore")
def _default_compact_shortcut_buttons() -> list[CompactShortcutButton]:
return [
CompactShortcutButton(
id="default-compact-shortcut",
label="快捷入口",
url="",
enabled=True,
sort_order=0,
)
]
class AppearanceSettings(BaseModel): class AppearanceSettings(BaseModel):
"""System-wide appearance defaults that any *new* user inherits. """System-wide appearance defaults that any *new* user inherits.
@@ -63,6 +118,10 @@ class AppearanceSettings(BaseModel):
default="full", default="full",
description="Default interface layout for users who haven't picked their own — full (sidebar + header) / compact (hidden)", description="Default interface layout for users who haven't picked their own — full (sidebar + header) / compact (hidden)",
) )
compact_shortcut_buttons: list[CompactShortcutButton] = Field(
default_factory=_default_compact_shortcut_buttons,
description="Shortcut buttons rendered before the theme switcher in top bars",
)
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@@ -12,6 +12,8 @@
6. 让用户选择一个或多个关键传播节点生成策略并入库;受众和节点不能在同一步交叉。 6. 让用户选择一个或多个关键传播节点生成策略并入库;受众和节点不能在同一步交叉。
7. 同步前端步骤状态,保证原 `item-aiceh` 页面步骤条可继续识别。 7. 同步前端步骤状态,保证原 `item-aiceh` 页面步骤条可继续识别。
策略入库归属由步骤强制决定:Step5 固定写“目标受众账号”和 `designId + strategyId` 关系,Step6 固定写“关键传播节点账号”和 `pathwayId + strategyId` 关系,不用模型返回的 `accountType` 判断归属。
## 技能位置 ## 技能位置
```text ```text
@@ -146,6 +148,8 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
策略信息形式/模态会按前端 `modelType` 字典保存为 value 逗号串,例如模型输出“图片,视频”时,脚本先查 `sys/sys-dict-data` 再写入 `task-action-message.modelType`,不会直接保存中文文字。 策略信息形式/模态会按前端 `modelType` 字典保存为 value 逗号串,例如模型输出“图片,视频”时,脚本先查 `sys/sys-dict-data` 再写入 `task-action-message.modelType`,不会直接保存中文文字。
策略关系表写入时,Step5 只写受众 `designId`,Step6 只写传播节点 `pathwayId`;即使模型把 `账号类型/accountType` 生成反了,脚本也会按当前步骤强制纠正,避免受众策略和传播节点策略串写。
## 输出文件 ## 输出文件
执行后查看: 执行后查看:
@@ -73,6 +73,8 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
Step5 只能选择受众生成策略;Step6 只能选择关键传播节点生成策略。不要在同一步里同时传 `--select-strategy-audiences` 和 `--select-strategy-pathways`,受众和关键传播节点不能交叉生成策略。 Step5 只能选择受众生成策略;Step6 只能选择关键传播节点生成策略。不要在同一步里同时传 `--select-strategy-audiences` 和 `--select-strategy-pathways`,受众和关键传播节点不能交叉生成策略。
策略归属必须由当前步骤强制决定,不能相信模型返回的 `账号类型/accountType`:Step5 固定按“目标受众账号”保存,只写 `designId + strategyId` 关系;Step6 固定按“关键传播节点账号”保存,只写 `pathwayId + strategyId` 关系,严禁把受众策略写到关键传播节点或反向串写。
脚本每次调用接口前还会输出 `操作反馈 {...}`,包含当前操作、HTTP 方法和 URL。智能体必须用简短中文告诉用户“正在做什么、调用哪个接口”。 脚本每次调用接口前还会输出 `操作反馈 {...}`,包含当前操作、HTTP 方法和 URL。智能体必须用简短中文告诉用户“正在做什么、调用哪个接口”。
每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`,包含 `入库对象类型`、`入库对象名称`、`返回ID` 和 `接口路径`。智能体必须把它转述成“已入库 xxx 受众 / xxx 脆弱点 / xxx 策略”等明确日志。 每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`,包含 `入库对象类型`、`入库对象名称`、`返回ID` 和 `接口路径`。智能体必须把它转述成“已入库 xxx 受众 / xxx 脆弱点 / xxx 策略”等明确日志。
@@ -106,7 +108,7 @@ python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run
- 每个被选中的受众/传播节点默认生成最多 3 个策略,可用 `--max-strategies` 调整。 - 每个被选中的受众/传播节点默认生成最多 3 个策略,可用 `--max-strategies` 调整。
- 每个保存接口必须拿到返回 `id` 才进入下游。 - 每个保存接口必须拿到返回 `id` 才进入下游。
- 保存后会同步前端 `task/task-action.step` 步骤状态;如只想入库不更新步骤条,追加 `--no-step-status`。 - 保存后会同步前端 `task/task-action.step` 步骤状态;如只想入库不更新步骤条,追加 `--no-step-status`。
- 策略保存会同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`,兼容策略表格/编排页面读取。 - 策略保存会同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`,兼容策略表格/编排页面读取;Step5 只写受众 `designId` 关系,Step6 只写传播节点 `pathwayId` 关系,归属不使用模型返回的 `accountType` 判断。
- 策略 `信息形式/模态` 入库前会读取 `sys/sys-dict-data` 的 `modelType` 字典,把模型返回的“图片,视频/文本,图片”等文字转成前端需要的 value 逗号串;字典读取失败时使用内置兜底映射。 - 策略 `信息形式/模态` 入库前会读取 `sys/sys-dict-data` 的 `modelType` 字典,把模型返回的“图片,视频/文本,图片”等文字转成前端需要的 value 逗号串;字典读取失败时使用内置兜底映射。
- Step1 的受众目标抽取结果里,`用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本会按这个索引依次找到账号并保存受众账号关系。 - Step1 的受众目标抽取结果里,`用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本会按这个索引依次找到账号并保存受众账号关系。
- 模型流式 `{"answer":"..."}` 会自动拼接。 - 模型流式 `{"answer":"..."}` 会自动拼接。
@@ -2093,6 +2093,15 @@ def strategy_language(value: Any) -> int:
return as_int(value) or 3 return as_int(value) or 3
STRATEGY_ACCOUNT_TYPE_AUDIENCE = "目标受众账号"
STRATEGY_ACCOUNT_TYPE_PATHWAY = "关键传播节点账号"
def strategy_account_type(*, audience: bool) -> str:
"""Return the fixed account type for the current strategy step."""
return STRATEGY_ACCOUNT_TYPE_AUDIENCE if audience else STRATEGY_ACCOUNT_TYPE_PATHWAY
def save_strategy_details( def save_strategy_details(
ctx: FlowContext, ctx: FlowContext,
state: dict[str, Any], state: dict[str, Any],
@@ -2112,15 +2121,7 @@ def save_strategy_details(
platform = strategy_platform( platform = strategy_platform(
first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})) first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
) )
account_type = str( account_type = strategy_account_type(audience=audience)
first_present(
strategy.get("accountType"),
item.get("accountType"),
item.get("type"),
"目标受众账号" if audience else "关键传播节点账号",
)
or ""
).strip()
persona = strategy.get("persona") or "专业议题分析者" persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3) persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10) responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
@@ -2164,8 +2165,18 @@ def save_strategy_details(
stage + "/save-message", stage + "/save-message",
) )
design_ids = [item.get("designId")] if audience else item.get("designIds") or [] # Strategy ownership must never be inferred from model text or mixed
pathway_ids = [] if audience else [item.get("pathwayAccountId")] # relations: Step5 writes only audience designId, Step6 writes only pathwayId.
if audience:
design_ids = [item.get("designId")]
pathway_ids: list[Any] = []
if not any(design_ids):
record_error(state, stage, "受众策略缺少 designId,无法写入受众策略关系", request=item)
else:
design_ids = []
pathway_ids = [first_present(item.get("pathwayAccountId"), item.get("pathwayId"))]
if not any(pathway_ids):
record_error(state, stage, "关键传播节点策略缺少 pathwayId,无法写入传播节点策略关系", request=item)
for design_id in [x for x in design_ids if x]: for design_id in [x for x in design_ids if x]:
save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation") save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation")
for pathway_id in [x for x in pathway_ids if x]: for pathway_id in [x for x in pathway_ids if x]:
@@ -2901,7 +2912,16 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
strategies = extract_strategies(data, ctx.max_strategies) strategies = extract_strategies(data, ctx.max_strategies)
for strategy in strategies: for strategy in strategies:
target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "") target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "")
account_type = strategy.get("accountType") or ("目标受众账号" if audience else "关键传播节点账号") account_type = strategy_account_type(audience=audience)
returned_account_type = str(first_present(strategy.get("accountType"), item.get("accountType"), item.get("type"), "") or "").strip()
if returned_account_type and returned_account_type != account_type:
record_event(
state,
stage,
f"模型返回账号类型“{returned_account_type}”与当前步骤不一致,已强制按“{account_type}”入库",
{"strategy": strategy, "item": item},
)
strategy_for_save = {**strategy, "accountType": account_type}
topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题" topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题"
if not strategy.get("title"): if not strategy.get("title"):
record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data) record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data)
@@ -2922,9 +2942,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
if strategy_id is not None: if strategy_id is not None:
# Frontend strategy pages read these child tables, so a strategy # Frontend strategy pages read these child tables, so a strategy
# is not complete until this helper succeeds as far as possible. # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience) save_strategy_details(ctx, state, strategy_id, item, strategy_for_save, topic, stage, audience=audience)
existing_topics.add(topic) existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp}) state[output_key].append({**strategy_for_save, "id": strategy_id, "targetAudience": target, "topic": topic, "saveResponse": resp})
if state[output_key]: if state[output_key]:
mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status") mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status")
record_event(state, stage, "完成") record_event(state, stage, "完成")
+3 -1
View File
@@ -58,6 +58,8 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
Step5 只能选择受众生成策略;Step6 只能选择关键传播节点生成策略。总控不能在同一步混传 `--select-strategy-audiences` 和 `--select-strategy-pathways`。 Step5 只能选择受众生成策略;Step6 只能选择关键传播节点生成策略。总控不能在同一步混传 `--select-strategy-audiences` 和 `--select-strategy-pathways`。
策略归属必须由当前步骤强制决定,不能相信模型返回的 `账号类型/accountType`:Step5 固定按“目标受众账号”保存,只写 `designId + strategyId` 关系;Step6 固定按“关键传播节点账号”保存,只写 `pathwayId + strategyId` 关系,严禁把受众策略写到关键传播节点或反向串写。
每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`。总控必须告诉用户已入库的对象类型、名称和返回 ID,例如“已入库关键传播节点:xxx,pathwayAccountId=123”。 每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`。总控必须告诉用户已入库的对象类型、名称和返回 ID,例如“已入库关键传播节点:xxx,pathwayAccountId=123”。
如果接口或模型处理报错,会立即输出 `错误反馈 {...}`。总控必须立刻把失败阶段、URL、状态码、请求摘要和响应摘要告诉用户。 如果接口或模型处理报错,会立即输出 `错误反馈 {...}`。总控必须立刻把失败阶段、URL、状态码、请求摘要和响应摘要告诉用户。
@@ -95,7 +97,7 @@ python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-
- Step4/Step6 的传播节点下游分析默认从已入库传播节点中选择前 2 个继续;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 - Step4/Step6 的传播节点下游分析默认从已入库传播节点中选择前 2 个继续;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
- Step3-Step6 对上述选中的每个受众/传播节点默认生成最多 3 个脆弱点或策略。 - Step3-Step6 对上述选中的每个受众/传播节点默认生成最多 3 个脆弱点或策略。
- 各步骤会同步前端 `task/task-action.step`;需要禁用时每条命令追加 `--no-step-status`。 - 各步骤会同步前端 `task/task-action.step`;需要禁用时每条命令追加 `--no-step-status`。
- 策略步骤会同步写入 channel、narrate、message、策略与受众/传播节点关系表,保证原前端策略表格/编排页可继续读取。 - 策略步骤会同步写入 channel、narrate、message、策略与受众/传播节点关系表,保证原前端策略表格/编排页可继续读取;Step5 只写受众 `designId` 关系,Step6 只写传播节点 `pathwayId` 关系,归属不使用模型返回的 `accountType` 判断。
- 策略步骤保存 `信息形式/模态` 时,必须使用脚本读取 `sys/sys-dict-data` 的 `modelType` 字典并把文字转成 value 逗号串,不能把“图片,视频”等文字直接写入 `task-action-message.modelType`。 - 策略步骤保存 `信息形式/模态` 时,必须使用脚本读取 `sys/sys-dict-data` 的 `modelType` 字典并把文字转成 value 逗号串,不能把“图片,视频”等文字直接写入 `task-action-message.modelType`。
- 每个步骤完成都会输出 `步骤反馈`,总控必须逐步转述给用户。 - 每个步骤完成都会输出 `步骤反馈`,总控必须逐步转述给用户。
- 每次操作和接口调用都会输出 `操作反馈`,总控必须逐条转述当前操作。 - 每次操作和接口调用都会输出 `操作反馈`,总控必须逐条转述当前操作。
@@ -2093,6 +2093,15 @@ def strategy_language(value: Any) -> int:
return as_int(value) or 3 return as_int(value) or 3
STRATEGY_ACCOUNT_TYPE_AUDIENCE = "目标受众账号"
STRATEGY_ACCOUNT_TYPE_PATHWAY = "关键传播节点账号"
def strategy_account_type(*, audience: bool) -> str:
"""Return the fixed account type for the current strategy step."""
return STRATEGY_ACCOUNT_TYPE_AUDIENCE if audience else STRATEGY_ACCOUNT_TYPE_PATHWAY
def save_strategy_details( def save_strategy_details(
ctx: FlowContext, ctx: FlowContext,
state: dict[str, Any], state: dict[str, Any],
@@ -2112,15 +2121,7 @@ def save_strategy_details(
platform = strategy_platform( platform = strategy_platform(
first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})) first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
) )
account_type = str( account_type = strategy_account_type(audience=audience)
first_present(
strategy.get("accountType"),
item.get("accountType"),
item.get("type"),
"目标受众账号" if audience else "关键传播节点账号",
)
or ""
).strip()
persona = strategy.get("persona") or "专业议题分析者" persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3) persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10) responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
@@ -2164,8 +2165,18 @@ def save_strategy_details(
stage + "/save-message", stage + "/save-message",
) )
design_ids = [item.get("designId")] if audience else item.get("designIds") or [] # Strategy ownership must never be inferred from model text or mixed
pathway_ids = [] if audience else [item.get("pathwayAccountId")] # relations: Step5 writes only audience designId, Step6 writes only pathwayId.
if audience:
design_ids = [item.get("designId")]
pathway_ids: list[Any] = []
if not any(design_ids):
record_error(state, stage, "受众策略缺少 designId,无法写入受众策略关系", request=item)
else:
design_ids = []
pathway_ids = [first_present(item.get("pathwayAccountId"), item.get("pathwayId"))]
if not any(pathway_ids):
record_error(state, stage, "关键传播节点策略缺少 pathwayId,无法写入传播节点策略关系", request=item)
for design_id in [x for x in design_ids if x]: for design_id in [x for x in design_ids if x]:
save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation") save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation")
for pathway_id in [x for x in pathway_ids if x]: for pathway_id in [x for x in pathway_ids if x]:
@@ -2901,7 +2912,16 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
strategies = extract_strategies(data, ctx.max_strategies) strategies = extract_strategies(data, ctx.max_strategies)
for strategy in strategies: for strategy in strategies:
target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "") target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "")
account_type = strategy.get("accountType") or ("目标受众账号" if audience else "关键传播节点账号") account_type = strategy_account_type(audience=audience)
returned_account_type = str(first_present(strategy.get("accountType"), item.get("accountType"), item.get("type"), "") or "").strip()
if returned_account_type and returned_account_type != account_type:
record_event(
state,
stage,
f"模型返回账号类型“{returned_account_type}”与当前步骤不一致,已强制按“{account_type}”入库",
{"strategy": strategy, "item": item},
)
strategy_for_save = {**strategy, "accountType": account_type}
topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题" topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题"
if not strategy.get("title"): if not strategy.get("title"):
record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data) record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data)
@@ -2922,9 +2942,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
if strategy_id is not None: if strategy_id is not None:
# Frontend strategy pages read these child tables, so a strategy # Frontend strategy pages read these child tables, so a strategy
# is not complete until this helper succeeds as far as possible. # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience) save_strategy_details(ctx, state, strategy_id, item, strategy_for_save, topic, stage, audience=audience)
existing_topics.add(topic) existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp}) state[output_key].append({**strategy_for_save, "id": strategy_id, "targetAudience": target, "topic": topic, "saveResponse": resp})
if state[output_key]: if state[output_key]:
mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status") mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status")
record_event(state, stage, "完成") record_event(state, stage, "完成")
+3 -2
View File
@@ -141,6 +141,7 @@ python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-
- Step4/Step6 的传播节点下游分析默认从已入库传播节点中选择前 2 个继续;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。 - Step4/Step6 的传播节点下游分析默认从已入库传播节点中选择前 2 个继续;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
- Step3-Step6 对上述选中的每个受众/传播节点默认生成最多 3 个脆弱点或策略。 - Step3-Step6 对上述选中的每个受众/传播节点默认生成最多 3 个脆弱点或策略。
- Step5/Step6 的策略模态必须按前端 `modelType` 字典保存为 value 逗号串;脚本会自动把“图片,视频/文本,图片”等文字转成 `task-action-message.modelType` 需要的值。 - Step5/Step6 的策略模态必须按前端 `modelType` 字典保存为 value 逗号串;脚本会自动把“图片,视频/文本,图片”等文字转成 `task-action-message.modelType` 需要的值。
- Step5/Step6 的策略归属由步骤强制决定:Step5 固定写“目标受众账号”和 `designId + strategyId` 关系,Step6 固定写“关键传播节点账号”和 `pathwayId + strategyId` 关系,不用模型返回的 `accountType` 判断归属。
总控反馈用户时建议使用这种格式: 总控反馈用户时建议使用这种格式:
@@ -233,7 +234,7 @@ pathwayVulns
- 输出 `用户协助`,等待用户选择一个或多个受众;带 `--select-strategy-audiences` 重跑后才生成并保存这些受众的策略。 - 输出 `用户协助`,等待用户选择一个或多个受众;带 `--select-strategy-audiences` 重跑后才生成并保存这些受众的策略。
- 本步骤只能选择受众,不能选择或混入关键传播节点。 - 本步骤只能选择受众,不能选择或混入关键传播节点。
- 保存策略主表。 - 保存策略主表。
- 同步写入策略 channel、narrate、message 和策略-受众关系。 - 同步写入策略 channel、narrate、message 和策略-受众关系;只写 `designId + strategyId`,即使模型返回关键传播节点账号类型也要按受众策略保存。
- 同步前端 `step-analysis-strategy`、`step-analysis-strategy-gantt` 状态。 - 同步前端 `step-analysis-strategy`、`step-analysis-strategy-gantt` 状态。
输出写入: 输出写入:
@@ -252,7 +253,7 @@ audienceStrategies
- 输出 `用户协助`,等待用户选择一个或多个关键传播节点;带 `--select-strategy-pathways` 重跑后才生成并保存这些传播节点的策略。 - 输出 `用户协助`,等待用户选择一个或多个关键传播节点;带 `--select-strategy-pathways` 重跑后才生成并保存这些传播节点的策略。
- 本步骤只能选择关键传播节点,不能选择或混入受众。 - 本步骤只能选择关键传播节点,不能选择或混入受众。
- 保存策略主表。 - 保存策略主表。
- 同步写入策略 channel、narrate、message 和策略-传播节点关系。 - 同步写入策略 channel、narrate、message 和策略-传播节点关系;只写 `pathwayId + strategyId`,即使模型返回受众账号类型也要按传播节点策略保存。
- 同步前端 `step-analysis-strategy`、`step-analysis-strategy-gantt` 状态。 - 同步前端 `step-analysis-strategy`、`step-analysis-strategy-gantt` 状态。
输出写入: 输出写入:
@@ -2093,6 +2093,15 @@ def strategy_language(value: Any) -> int:
return as_int(value) or 3 return as_int(value) or 3
STRATEGY_ACCOUNT_TYPE_AUDIENCE = "目标受众账号"
STRATEGY_ACCOUNT_TYPE_PATHWAY = "关键传播节点账号"
def strategy_account_type(*, audience: bool) -> str:
"""Return the fixed account type for the current strategy step."""
return STRATEGY_ACCOUNT_TYPE_AUDIENCE if audience else STRATEGY_ACCOUNT_TYPE_PATHWAY
def save_strategy_details( def save_strategy_details(
ctx: FlowContext, ctx: FlowContext,
state: dict[str, Any], state: dict[str, Any],
@@ -2112,15 +2121,7 @@ def save_strategy_details(
platform = strategy_platform( platform = strategy_platform(
first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})) first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
) )
account_type = str( account_type = strategy_account_type(audience=audience)
first_present(
strategy.get("accountType"),
item.get("accountType"),
item.get("type"),
"目标受众账号" if audience else "关键传播节点账号",
)
or ""
).strip()
persona = strategy.get("persona") or "专业议题分析者" persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3) persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10) responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
@@ -2164,8 +2165,18 @@ def save_strategy_details(
stage + "/save-message", stage + "/save-message",
) )
design_ids = [item.get("designId")] if audience else item.get("designIds") or [] # Strategy ownership must never be inferred from model text or mixed
pathway_ids = [] if audience else [item.get("pathwayAccountId")] # relations: Step5 writes only audience designId, Step6 writes only pathwayId.
if audience:
design_ids = [item.get("designId")]
pathway_ids: list[Any] = []
if not any(design_ids):
record_error(state, stage, "受众策略缺少 designId,无法写入受众策略关系", request=item)
else:
design_ids = []
pathway_ids = [first_present(item.get("pathwayAccountId"), item.get("pathwayId"))]
if not any(pathway_ids):
record_error(state, stage, "关键传播节点策略缺少 pathwayId,无法写入传播节点策略关系", request=item)
for design_id in [x for x in design_ids if x]: for design_id in [x for x in design_ids if x]:
save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation") save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation")
for pathway_id in [x for x in pathway_ids if x]: for pathway_id in [x for x in pathway_ids if x]:
@@ -2901,7 +2912,16 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
strategies = extract_strategies(data, ctx.max_strategies) strategies = extract_strategies(data, ctx.max_strategies)
for strategy in strategies: for strategy in strategies:
target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "") target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "")
account_type = strategy.get("accountType") or ("目标受众账号" if audience else "关键传播节点账号") account_type = strategy_account_type(audience=audience)
returned_account_type = str(first_present(strategy.get("accountType"), item.get("accountType"), item.get("type"), "") or "").strip()
if returned_account_type and returned_account_type != account_type:
record_event(
state,
stage,
f"模型返回账号类型“{returned_account_type}”与当前步骤不一致,已强制按“{account_type}”入库",
{"strategy": strategy, "item": item},
)
strategy_for_save = {**strategy, "accountType": account_type}
topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题" topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题"
if not strategy.get("title"): if not strategy.get("title"):
record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data) record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data)
@@ -2922,9 +2942,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
if strategy_id is not None: if strategy_id is not None:
# Frontend strategy pages read these child tables, so a strategy # Frontend strategy pages read these child tables, so a strategy
# is not complete until this helper succeeds as far as possible. # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience) save_strategy_details(ctx, state, strategy_id, item, strategy_for_save, topic, stage, audience=audience)
existing_topics.add(topic) existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp}) state[output_key].append({**strategy_for_save, "id": strategy_id, "targetAudience": target, "topic": topic, "saveResponse": resp})
if state[output_key]: if state[output_key]:
mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status") mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status")
record_event(state, stage, "完成") record_event(state, stage, "完成")
@@ -2093,6 +2093,15 @@ def strategy_language(value: Any) -> int:
return as_int(value) or 3 return as_int(value) or 3
STRATEGY_ACCOUNT_TYPE_AUDIENCE = "目标受众账号"
STRATEGY_ACCOUNT_TYPE_PATHWAY = "关键传播节点账号"
def strategy_account_type(*, audience: bool) -> str:
"""Return the fixed account type for the current strategy step."""
return STRATEGY_ACCOUNT_TYPE_AUDIENCE if audience else STRATEGY_ACCOUNT_TYPE_PATHWAY
def save_strategy_details( def save_strategy_details(
ctx: FlowContext, ctx: FlowContext,
state: dict[str, Any], state: dict[str, Any],
@@ -2112,15 +2121,7 @@ def save_strategy_details(
platform = strategy_platform( platform = strategy_platform(
first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})) first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
) )
account_type = str( account_type = strategy_account_type(audience=audience)
first_present(
strategy.get("accountType"),
item.get("accountType"),
item.get("type"),
"目标受众账号" if audience else "关键传播节点账号",
)
or ""
).strip()
persona = strategy.get("persona") or "专业议题分析者" persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3) persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10) responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
@@ -2164,8 +2165,18 @@ def save_strategy_details(
stage + "/save-message", stage + "/save-message",
) )
design_ids = [item.get("designId")] if audience else item.get("designIds") or [] # Strategy ownership must never be inferred from model text or mixed
pathway_ids = [] if audience else [item.get("pathwayAccountId")] # relations: Step5 writes only audience designId, Step6 writes only pathwayId.
if audience:
design_ids = [item.get("designId")]
pathway_ids: list[Any] = []
if not any(design_ids):
record_error(state, stage, "受众策略缺少 designId,无法写入受众策略关系", request=item)
else:
design_ids = []
pathway_ids = [first_present(item.get("pathwayAccountId"), item.get("pathwayId"))]
if not any(pathway_ids):
record_error(state, stage, "关键传播节点策略缺少 pathwayId,无法写入传播节点策略关系", request=item)
for design_id in [x for x in design_ids if x]: for design_id in [x for x in design_ids if x]:
save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation") save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation")
for pathway_id in [x for x in pathway_ids if x]: for pathway_id in [x for x in pathway_ids if x]:
@@ -2901,7 +2912,16 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
strategies = extract_strategies(data, ctx.max_strategies) strategies = extract_strategies(data, ctx.max_strategies)
for strategy in strategies: for strategy in strategies:
target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "") target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "")
account_type = strategy.get("accountType") or ("目标受众账号" if audience else "关键传播节点账号") account_type = strategy_account_type(audience=audience)
returned_account_type = str(first_present(strategy.get("accountType"), item.get("accountType"), item.get("type"), "") or "").strip()
if returned_account_type and returned_account_type != account_type:
record_event(
state,
stage,
f"模型返回账号类型“{returned_account_type}”与当前步骤不一致,已强制按“{account_type}”入库",
{"strategy": strategy, "item": item},
)
strategy_for_save = {**strategy, "accountType": account_type}
topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题" topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题"
if not strategy.get("title"): if not strategy.get("title"):
record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data) record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data)
@@ -2922,9 +2942,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
if strategy_id is not None: if strategy_id is not None:
# Frontend strategy pages read these child tables, so a strategy # Frontend strategy pages read these child tables, so a strategy
# is not complete until this helper succeeds as far as possible. # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience) save_strategy_details(ctx, state, strategy_id, item, strategy_for_save, topic, stage, audience=audience)
existing_topics.add(topic) existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp}) state[output_key].append({**strategy_for_save, "id": strategy_id, "targetAudience": target, "topic": topic, "saveResponse": resp})
if state[output_key]: if state[output_key]:
mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status") mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status")
record_event(state, stage, "完成") record_event(state, stage, "完成")
@@ -2093,6 +2093,15 @@ def strategy_language(value: Any) -> int:
return as_int(value) or 3 return as_int(value) or 3
STRATEGY_ACCOUNT_TYPE_AUDIENCE = "目标受众账号"
STRATEGY_ACCOUNT_TYPE_PATHWAY = "关键传播节点账号"
def strategy_account_type(*, audience: bool) -> str:
"""Return the fixed account type for the current strategy step."""
return STRATEGY_ACCOUNT_TYPE_AUDIENCE if audience else STRATEGY_ACCOUNT_TYPE_PATHWAY
def save_strategy_details( def save_strategy_details(
ctx: FlowContext, ctx: FlowContext,
state: dict[str, Any], state: dict[str, Any],
@@ -2112,15 +2121,7 @@ def save_strategy_details(
platform = strategy_platform( platform = strategy_platform(
first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})) first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
) )
account_type = str( account_type = strategy_account_type(audience=audience)
first_present(
strategy.get("accountType"),
item.get("accountType"),
item.get("type"),
"目标受众账号" if audience else "关键传播节点账号",
)
or ""
).strip()
persona = strategy.get("persona") or "专业议题分析者" persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3) persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10) responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
@@ -2164,8 +2165,18 @@ def save_strategy_details(
stage + "/save-message", stage + "/save-message",
) )
design_ids = [item.get("designId")] if audience else item.get("designIds") or [] # Strategy ownership must never be inferred from model text or mixed
pathway_ids = [] if audience else [item.get("pathwayAccountId")] # relations: Step5 writes only audience designId, Step6 writes only pathwayId.
if audience:
design_ids = [item.get("designId")]
pathway_ids: list[Any] = []
if not any(design_ids):
record_error(state, stage, "受众策略缺少 designId,无法写入受众策略关系", request=item)
else:
design_ids = []
pathway_ids = [first_present(item.get("pathwayAccountId"), item.get("pathwayId"))]
if not any(pathway_ids):
record_error(state, stage, "关键传播节点策略缺少 pathwayId,无法写入传播节点策略关系", request=item)
for design_id in [x for x in design_ids if x]: for design_id in [x for x in design_ids if x]:
save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation") save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation")
for pathway_id in [x for x in pathway_ids if x]: for pathway_id in [x for x in pathway_ids if x]:
@@ -2901,7 +2912,16 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
strategies = extract_strategies(data, ctx.max_strategies) strategies = extract_strategies(data, ctx.max_strategies)
for strategy in strategies: for strategy in strategies:
target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "") target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "")
account_type = strategy.get("accountType") or ("目标受众账号" if audience else "关键传播节点账号") account_type = strategy_account_type(audience=audience)
returned_account_type = str(first_present(strategy.get("accountType"), item.get("accountType"), item.get("type"), "") or "").strip()
if returned_account_type and returned_account_type != account_type:
record_event(
state,
stage,
f"模型返回账号类型“{returned_account_type}”与当前步骤不一致,已强制按“{account_type}”入库",
{"strategy": strategy, "item": item},
)
strategy_for_save = {**strategy, "accountType": account_type}
topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题" topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题"
if not strategy.get("title"): if not strategy.get("title"):
record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data) record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data)
@@ -2922,9 +2942,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
if strategy_id is not None: if strategy_id is not None:
# Frontend strategy pages read these child tables, so a strategy # Frontend strategy pages read these child tables, so a strategy
# is not complete until this helper succeeds as far as possible. # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience) save_strategy_details(ctx, state, strategy_id, item, strategy_for_save, topic, stage, audience=audience)
existing_topics.add(topic) existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp}) state[output_key].append({**strategy_for_save, "id": strategy_id, "targetAudience": target, "topic": topic, "saveResponse": resp})
if state[output_key]: if state[output_key]:
mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status") mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status")
record_event(state, stage, "完成") record_event(state, stage, "完成")
@@ -2093,6 +2093,15 @@ def strategy_language(value: Any) -> int:
return as_int(value) or 3 return as_int(value) or 3
STRATEGY_ACCOUNT_TYPE_AUDIENCE = "目标受众账号"
STRATEGY_ACCOUNT_TYPE_PATHWAY = "关键传播节点账号"
def strategy_account_type(*, audience: bool) -> str:
"""Return the fixed account type for the current strategy step."""
return STRATEGY_ACCOUNT_TYPE_AUDIENCE if audience else STRATEGY_ACCOUNT_TYPE_PATHWAY
def save_strategy_details( def save_strategy_details(
ctx: FlowContext, ctx: FlowContext,
state: dict[str, Any], state: dict[str, Any],
@@ -2112,15 +2121,7 @@ def save_strategy_details(
platform = strategy_platform( platform = strategy_platform(
first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})) first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
) )
account_type = str( account_type = strategy_account_type(audience=audience)
first_present(
strategy.get("accountType"),
item.get("accountType"),
item.get("type"),
"目标受众账号" if audience else "关键传播节点账号",
)
or ""
).strip()
persona = strategy.get("persona") or "专业议题分析者" persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3) persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10) responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
@@ -2164,8 +2165,18 @@ def save_strategy_details(
stage + "/save-message", stage + "/save-message",
) )
design_ids = [item.get("designId")] if audience else item.get("designIds") or [] # Strategy ownership must never be inferred from model text or mixed
pathway_ids = [] if audience else [item.get("pathwayAccountId")] # relations: Step5 writes only audience designId, Step6 writes only pathwayId.
if audience:
design_ids = [item.get("designId")]
pathway_ids: list[Any] = []
if not any(design_ids):
record_error(state, stage, "受众策略缺少 designId,无法写入受众策略关系", request=item)
else:
design_ids = []
pathway_ids = [first_present(item.get("pathwayAccountId"), item.get("pathwayId"))]
if not any(pathway_ids):
record_error(state, stage, "关键传播节点策略缺少 pathwayId,无法写入传播节点策略关系", request=item)
for design_id in [x for x in design_ids if x]: for design_id in [x for x in design_ids if x]:
save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation") save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation")
for pathway_id in [x for x in pathway_ids if x]: for pathway_id in [x for x in pathway_ids if x]:
@@ -2901,7 +2912,16 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
strategies = extract_strategies(data, ctx.max_strategies) strategies = extract_strategies(data, ctx.max_strategies)
for strategy in strategies: for strategy in strategies:
target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "") target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "")
account_type = strategy.get("accountType") or ("目标受众账号" if audience else "关键传播节点账号") account_type = strategy_account_type(audience=audience)
returned_account_type = str(first_present(strategy.get("accountType"), item.get("accountType"), item.get("type"), "") or "").strip()
if returned_account_type and returned_account_type != account_type:
record_event(
state,
stage,
f"模型返回账号类型“{returned_account_type}”与当前步骤不一致,已强制按“{account_type}”入库",
{"strategy": strategy, "item": item},
)
strategy_for_save = {**strategy, "accountType": account_type}
topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题" topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题"
if not strategy.get("title"): if not strategy.get("title"):
record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data) record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data)
@@ -2922,9 +2942,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
if strategy_id is not None: if strategy_id is not None:
# Frontend strategy pages read these child tables, so a strategy # Frontend strategy pages read these child tables, so a strategy
# is not complete until this helper succeeds as far as possible. # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience) save_strategy_details(ctx, state, strategy_id, item, strategy_for_save, topic, stage, audience=audience)
existing_topics.add(topic) existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp}) state[output_key].append({**strategy_for_save, "id": strategy_id, "targetAudience": target, "topic": topic, "saveResponse": resp})
if state[output_key]: if state[output_key]:
mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status") mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status")
record_event(state, stage, "完成") record_event(state, stage, "完成")
@@ -24,7 +24,7 @@ python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-fl
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。 - 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。 - 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。
- 调用策略保存接口。 - 调用策略保存接口。
- 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`。 - 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`;本步骤强制按“目标受众账号”保存,只写 `designId + strategyId` 受众关系,不能使用模型返回的 `账号类型/accountType` 判断归属。
- 保存 `task-action-message.modelType` 前会读取前端 `modelType` 字典并把“图片,视频/文本,图片”等模态文字转成 value 逗号串,避免策略表格回显为空。 - 保存 `task-action-message.modelType` 前会读取前端 `modelType` 字典并把“图片,视频/文本,图片”等模态文字转成 value 逗号串,避免策略表格回显为空。
- 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。 - 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。
- 保存成功后写入 `audienceStrategies`。 - 保存成功后写入 `audienceStrategies`。
@@ -2093,6 +2093,15 @@ def strategy_language(value: Any) -> int:
return as_int(value) or 3 return as_int(value) or 3
STRATEGY_ACCOUNT_TYPE_AUDIENCE = "目标受众账号"
STRATEGY_ACCOUNT_TYPE_PATHWAY = "关键传播节点账号"
def strategy_account_type(*, audience: bool) -> str:
"""Return the fixed account type for the current strategy step."""
return STRATEGY_ACCOUNT_TYPE_AUDIENCE if audience else STRATEGY_ACCOUNT_TYPE_PATHWAY
def save_strategy_details( def save_strategy_details(
ctx: FlowContext, ctx: FlowContext,
state: dict[str, Any], state: dict[str, Any],
@@ -2112,15 +2121,7 @@ def save_strategy_details(
platform = strategy_platform( platform = strategy_platform(
first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})) first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
) )
account_type = str( account_type = strategy_account_type(audience=audience)
first_present(
strategy.get("accountType"),
item.get("accountType"),
item.get("type"),
"目标受众账号" if audience else "关键传播节点账号",
)
or ""
).strip()
persona = strategy.get("persona") or "专业议题分析者" persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3) persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10) responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
@@ -2164,8 +2165,18 @@ def save_strategy_details(
stage + "/save-message", stage + "/save-message",
) )
design_ids = [item.get("designId")] if audience else item.get("designIds") or [] # Strategy ownership must never be inferred from model text or mixed
pathway_ids = [] if audience else [item.get("pathwayAccountId")] # relations: Step5 writes only audience designId, Step6 writes only pathwayId.
if audience:
design_ids = [item.get("designId")]
pathway_ids: list[Any] = []
if not any(design_ids):
record_error(state, stage, "受众策略缺少 designId,无法写入受众策略关系", request=item)
else:
design_ids = []
pathway_ids = [first_present(item.get("pathwayAccountId"), item.get("pathwayId"))]
if not any(pathway_ids):
record_error(state, stage, "关键传播节点策略缺少 pathwayId,无法写入传播节点策略关系", request=item)
for design_id in [x for x in design_ids if x]: for design_id in [x for x in design_ids if x]:
save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation") save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation")
for pathway_id in [x for x in pathway_ids if x]: for pathway_id in [x for x in pathway_ids if x]:
@@ -2901,7 +2912,16 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
strategies = extract_strategies(data, ctx.max_strategies) strategies = extract_strategies(data, ctx.max_strategies)
for strategy in strategies: for strategy in strategies:
target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "") target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "")
account_type = strategy.get("accountType") or ("目标受众账号" if audience else "关键传播节点账号") account_type = strategy_account_type(audience=audience)
returned_account_type = str(first_present(strategy.get("accountType"), item.get("accountType"), item.get("type"), "") or "").strip()
if returned_account_type and returned_account_type != account_type:
record_event(
state,
stage,
f"模型返回账号类型“{returned_account_type}”与当前步骤不一致,已强制按“{account_type}”入库",
{"strategy": strategy, "item": item},
)
strategy_for_save = {**strategy, "accountType": account_type}
topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题" topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题"
if not strategy.get("title"): if not strategy.get("title"):
record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data) record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data)
@@ -2922,9 +2942,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
if strategy_id is not None: if strategy_id is not None:
# Frontend strategy pages read these child tables, so a strategy # Frontend strategy pages read these child tables, so a strategy
# is not complete until this helper succeeds as far as possible. # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience) save_strategy_details(ctx, state, strategy_id, item, strategy_for_save, topic, stage, audience=audience)
existing_topics.add(topic) existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp}) state[output_key].append({**strategy_for_save, "id": strategy_id, "targetAudience": target, "topic": topic, "saveResponse": resp})
if state[output_key]: if state[output_key]:
mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status") mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status")
record_event(state, stage, "完成") record_event(state, stage, "完成")
@@ -24,7 +24,7 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。 - 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。 - 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。
- 调用策略保存接口。 - 调用策略保存接口。
- 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`。 - 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`;本步骤强制按“关键传播节点账号”保存,只写 `pathwayId + strategyId` 传播节点关系,不能使用模型返回的 `账号类型/accountType` 判断归属。
- 保存 `task-action-message.modelType` 前会读取前端 `modelType` 字典并把“图片,视频/文本,图片”等模态文字转成 value 逗号串,避免策略表格回显为空。 - 保存 `task-action-message.modelType` 前会读取前端 `modelType` 字典并把“图片,视频/文本,图片”等模态文字转成 value 逗号串,避免策略表格回显为空。
- 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。 - 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。
- 保存成功后写入 `pathwayStrategies`。 - 保存成功后写入 `pathwayStrategies`。
@@ -2093,6 +2093,15 @@ def strategy_language(value: Any) -> int:
return as_int(value) or 3 return as_int(value) or 3
STRATEGY_ACCOUNT_TYPE_AUDIENCE = "目标受众账号"
STRATEGY_ACCOUNT_TYPE_PATHWAY = "关键传播节点账号"
def strategy_account_type(*, audience: bool) -> str:
"""Return the fixed account type for the current strategy step."""
return STRATEGY_ACCOUNT_TYPE_AUDIENCE if audience else STRATEGY_ACCOUNT_TYPE_PATHWAY
def save_strategy_details( def save_strategy_details(
ctx: FlowContext, ctx: FlowContext,
state: dict[str, Any], state: dict[str, Any],
@@ -2112,15 +2121,7 @@ def save_strategy_details(
platform = strategy_platform( platform = strategy_platform(
first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {})) first_present(strategy.get("platform"), state.get("platform"), platform_label(item.get("account") or {}))
) )
account_type = str( account_type = strategy_account_type(audience=audience)
first_present(
strategy.get("accountType"),
item.get("accountType"),
item.get("type"),
"目标受众账号" if audience else "关键传播节点账号",
)
or ""
).strip()
persona = strategy.get("persona") or "专业议题分析者" persona = strategy.get("persona") or "专业议题分析者"
persona_size = chat_number(strategy.get("personaSize"), 3) persona_size = chat_number(strategy.get("personaSize"), 3)
responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10) responding_count = chat_number(first_present(strategy.get("respondingCount"), strategy.get("数量")), 10)
@@ -2164,8 +2165,18 @@ def save_strategy_details(
stage + "/save-message", stage + "/save-message",
) )
design_ids = [item.get("designId")] if audience else item.get("designIds") or [] # Strategy ownership must never be inferred from model text or mixed
pathway_ids = [] if audience else [item.get("pathwayAccountId")] # relations: Step5 writes only audience designId, Step6 writes only pathwayId.
if audience:
design_ids = [item.get("designId")]
pathway_ids: list[Any] = []
if not any(design_ids):
record_error(state, stage, "受众策略缺少 designId,无法写入受众策略关系", request=item)
else:
design_ids = []
pathway_ids = [first_present(item.get("pathwayAccountId"), item.get("pathwayId"))]
if not any(pathway_ids):
record_error(state, stage, "关键传播节点策略缺少 pathwayId,无法写入传播节点策略关系", request=item)
for design_id in [x for x in design_ids if x]: for design_id in [x for x in design_ids if x]:
save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation") save_with_id(ctx, state, "save_strategy_relation", {"designId": design_id, "strategyId": strategy_id}, stage + "/save-strategy-relation")
for pathway_id in [x for x in pathway_ids if x]: for pathway_id in [x for x in pathway_ids if x]:
@@ -2901,7 +2912,16 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
strategies = extract_strategies(data, ctx.max_strategies) strategies = extract_strategies(data, ctx.max_strategies)
for strategy in strategies: for strategy in strategies:
target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "") target = (target_names[0] if target_names else "") or (item.get("accountName") if not audience else "")
account_type = strategy.get("accountType") or ("目标受众账号" if audience else "关键传播节点账号") account_type = strategy_account_type(audience=audience)
returned_account_type = str(first_present(strategy.get("accountType"), item.get("accountType"), item.get("type"), "") or "").strip()
if returned_account_type and returned_account_type != account_type:
record_event(
state,
stage,
f"模型返回账号类型“{returned_account_type}”与当前步骤不一致,已强制按“{account_type}”入库",
{"strategy": strategy, "item": item},
)
strategy_for_save = {**strategy, "accountType": account_type}
topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题" topic = f"利用\"{strategy.get('persona') or '专业议题分析者'}\"人设,传播\"{strategy.get('title')}\"主题"
if not strategy.get("title"): if not strategy.get("title"):
record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data) record_error(state, stage, "策略缺少信息标题,跳过", request=strategy, response=data)
@@ -2922,9 +2942,9 @@ def run_strategy_step(ctx: FlowContext, state: dict[str, Any], *, audience: bool
if strategy_id is not None: if strategy_id is not None:
# Frontend strategy pages read these child tables, so a strategy # Frontend strategy pages read these child tables, so a strategy
# is not complete until this helper succeeds as far as possible. # is not complete until this helper succeeds as far as possible.
save_strategy_details(ctx, state, strategy_id, item, strategy, topic, stage, audience=audience) save_strategy_details(ctx, state, strategy_id, item, strategy_for_save, topic, stage, audience=audience)
existing_topics.add(topic) existing_topics.add(topic)
state[output_key].append({**strategy, "id": strategy_id, "targetAudience": target, "accountType": account_type, "topic": topic, "saveResponse": resp}) state[output_key].append({**strategy_for_save, "id": strategy_id, "targetAudience": target, "topic": topic, "saveResponse": resp})
if state[output_key]: if state[output_key]:
mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status") mark_step_status(ctx, state, ["step-analysis-strategy", "step-analysis-strategy-gantt"], stage + "/step-status")
record_event(state, stage, "完成") record_event(state, stage, "完成")