Merge branch 'dhk-1' into 'master'

统一外部 access_token 存储与轻应用 URL 拼接,修复嵌入会话鉴权隔离。

See merge request knowledge-base-bj/deerflow-server!165
This commit is contained in:
董宏凯 committed 2026-06-30 11:21:42 +00:00
commit c8cdd1d0b2
73 files changed
+31255 -2296

No files matched your search

@@ -138,6 +138,7 @@ export function AgentDetailPanel({
disabled,
defaultCollapsed,
showRecentChats,
compactAppearanceActions,
}: {
agent: Agent | null | undefined;
agentName: string;
@@ -147,6 +148,8 @@ export function AgentDetailPanel({
defaultCollapsed?: boolean;
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
showRecentChats?: boolean;
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
compactAppearanceActions?: ReactNode;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
@@ -830,6 +833,7 @@ export function AgentDetailPanel({
<span className="text-sm font-semibold">智能体信息</span>
<div className="flex items-center gap-1">
{headerActions}
{compactAppearanceActions}
{collapseButton}
</div>
</div>
@@ -841,17 +845,20 @@ export function AgentDetailPanel({
<>
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
{collapsed ? (
<div className="hidden shrink-0 flex-col items-center border-l pt-3 lg:flex">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(false)}
aria-label="展开智能体信息"
>
<PanelRightOpenIcon className="size-4" />
</Button>
</Tooltip>
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
<div className="flex items-center gap-1">
{compactAppearanceActions}
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(false)}
aria-label="展开智能体信息"
>
<PanelRightOpenIcon className="size-4" />
</Button>
</Tooltip>
</div>
</div>
) : (
<aside className="bg-card/40 hidden h-full w-[360px] shrink-0 flex-col border-l lg:flex">
@@ -211,6 +211,13 @@ export function AgentModuleSidebar({
<Link
key={`${keyPrefix}:${agent.id}`}
href={href}
onClick={() => {
try {
sessionStorage.setItem(`agent-module:${moduleKey}:last-agent`, agent.id);
} catch {
// Ignore storage failures; route navigation is the source of truth.
}
}}
className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors",
isActive
@@ -136,9 +136,11 @@ const LAYOUT_OPTIONS = [
export function LayoutModeSwitcher({
tone,
fallback = "compact",
navigateTo = "/page/workspace/chats/new",
}: {
tone: AppearanceControlTone;
fallback?: "full" | "compact";
navigateTo?: string | null;
}) {
const [mode, setLayoutMode] = useLayoutMode();
const navigate = useNavigate();
@@ -170,7 +172,9 @@ export function LayoutModeSwitcher({
key={option.id}
onClick={() => {
setLayoutMode(option.id);
navigate("/page/workspace/chats/new");
if (navigateTo) {
navigate(navigateTo);
}
}}
className="gap-2"
>
@@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { type ReactNode, useMemo } from "react";
import {
Breadcrumb,
@@ -30,11 +30,14 @@ export function WorkspaceContainer({
);
}
export function WorkspaceHeader({
className,
children,
...props
}: React.ComponentProps<"header">) {
export function WorkspaceHeader({
className,
children,
rightActions,
...props
}: React.ComponentProps<"header"> & {
rightActions?: ReactNode;
}) {
const { t } = useI18n();
const pathname = usePathname();
const segments = useMemo(() => {
@@ -51,8 +54,8 @@ export function WorkspaceHeader({
)}
{...props}
>
<div className="flex items-center gap-2 px-4">
<Breadcrumb>
<div className="flex items-center gap-2 px-4">
<Breadcrumb>
<BreadcrumbList>
{segments?.[0] && (
<BreadcrumbItem className="hidden md:block">
@@ -88,11 +91,14 @@ export function WorkspaceHeader({
</>
)}
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
);
}
</Breadcrumb>
</div>
{rightActions ? (
<div className="flex items-center gap-2 px-4">{rightActions}</div>
) : null}
</header>
);
}
export function WorkspaceBody({
className,
@@ -1,3 +1,7 @@
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
export const EXTERNAL_ACCESS_TOKEN_STORAGE_KEY = "access_token";
export function normalizeExternalAccessToken(value: unknown): string {
if (value == null) return "";
const token = String(value).trim();
@@ -5,3 +9,53 @@ export function normalizeExternalAccessToken(value: unknown): string {
const lowered = token.toLowerCase();
return lowered === "null" || lowered === "undefined" ? "" : token;
}
function readTokenFromStoredValue(raw: string | null): string {
const token = normalizeExternalAccessToken(raw);
if (!token) return "";
if (!token.startsWith("{")) return token;
try {
const parsed = JSON.parse(token);
if (!parsed || typeof parsed !== "object") return token;
const obj = parsed as Record<string, unknown>;
return (
normalizeExternalAccessToken(obj.access_token) ||
normalizeExternalAccessToken(obj.accessToken) ||
normalizeExternalAccessToken(obj.token)
);
} catch {
return token;
}
}
export function readStoredExternalAccessTokenValue(): string {
return readTokenFromStoredValue(authStorageGet(EXTERNAL_ACCESS_TOKEN_STORAGE_KEY));
}
export function readExternalAccessTokenValue(userInfo: Record<string, unknown>): string {
return normalizeExternalAccessToken(userInfo.access_token) || normalizeExternalAccessToken(userInfo.accessToken);
}
export function readCurrentExternalAccessTokenValue(userInfo: Record<string, unknown>): string {
return readStoredExternalAccessTokenValue() || readExternalAccessTokenValue(userInfo);
}
export function writeExternalAccessTokenFields(
userInfo: Record<string, unknown>,
token: string,
): void {
userInfo.access_token = token;
userInfo.accessToken = token;
}
export function writeStoredExternalAccessToken(token: string): void {
const normalized = normalizeExternalAccessToken(token);
if (normalized) {
authStorageSet(EXTERNAL_ACCESS_TOKEN_STORAGE_KEY, normalized);
}
}
export function deleteExternalAccessTokenFields(userInfo: Record<string, unknown>): void {
delete userInfo.access_token;
delete userInfo.accessToken;
}
+1 -1
View File
@@ -62,7 +62,7 @@ export function authStorageRemove(key: string): void {
* ticket/userId/env/maxkeyOnlineToken/userDetailToken)。用于「开启独立会话」时把当前登录态从
* localStorage 快照搬进 sessionStorage,避免切换存储后被登出。
*/
const PROTECTED_AUTH_EXACT_KEYS = ["deerflow.auth", "userInfo", "userId", "tools_token"];
const PROTECTED_AUTH_EXACT_KEYS = ["deerflow.auth", "userInfo", "userId", "tools_token", "access_token"];
const PROTECTED_AUTH_PREFIX = "login.";
function isProtectedAuthKey(key: string): boolean {
+102 -15
View File
@@ -1,6 +1,13 @@
import { requestCrudService } from "@/service/request-axios-service";
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
import {
deleteExternalAccessTokenFields,
normalizeExternalAccessToken,
readStoredExternalAccessTokenValue,
readExternalAccessTokenValue,
writeExternalAccessTokenFields,
writeStoredExternalAccessToken,
} from "@/core/auth/external-access-token";
const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || "";
@@ -36,6 +43,38 @@ function readStoredYUserId(info: Record<string, unknown>): string {
return value == null ? "" : String(value).trim();
}
function normalizeExternalUserId(value: unknown): string {
if (value == null) return "";
const text = String(value).trim();
if (!text) return "";
const lowered = text.toLowerCase();
return lowered === "null" || lowered === "undefined" ? "" : text;
}
function maskToken(token: string): string {
if (!token) return "";
if (token.length <= 10) return `${token.slice(0, 2)}***(${token.length})`;
return `${token.slice(0, 6)}...${token.slice(-4)}(${token.length})`;
}
function summarizeUserInfo(info: Record<string, unknown>): Record<string, unknown> {
return {
keys: Object.keys(info),
yUserId: info.yUserId,
userId: info.userId,
userName: info.userName,
gateDepartName: info.gateDepartName,
typeName: info.typeName,
access_token: maskToken(normalizeExternalAccessToken(info.access_token)),
accessToken: maskToken(normalizeExternalAccessToken(info.accessToken)),
};
}
function logYUserLogin(step: string, payload: Record<string, unknown>): void {
if (typeof console === "undefined") return;
console.info(`[yUserId-login] ${step}`, payload);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -84,6 +123,7 @@ function writeCachedYUserInfo(yUserId: string, info: Record<string, unknown>): v
function buildExternalUserInfo(
yUserId: string,
result: Record<string, unknown>,
access_token: string | null | undefined,
previous: Record<string, unknown>,
cached: Record<string, unknown>,
): Record<string, unknown> {
@@ -94,22 +134,34 @@ function buildExternalUserInfo(
}
}
next.yUserId = yUserId;
const externalUserId =
normalizeExternalUserId(result.id) ||
normalizeExternalUserId(result.userId) ||
normalizeExternalUserId(result.user_id) ||
yUserId;
next.userId = externalUserId;
const incomingAccessToken = normalizeExternalAccessToken(result.accessToken);
const incomingAccessToken = readExternalAccessTokenValue(result);
if (incomingAccessToken) {
next.accessToken = incomingAccessToken;
writeExternalAccessTokenFields(next, incomingAccessToken);
return next;
}
const queryAccessToken = normalizeExternalAccessToken(access_token);
if (queryAccessToken) {
writeExternalAccessTokenFields(next, queryAccessToken);
return next;
}
const previousYUserId = readStoredYUserId(previous);
const previousAccessToken =
previousYUserId === yUserId ? normalizeExternalAccessToken(previous.accessToken) : "";
const cachedAccessToken = normalizeExternalAccessToken(cached.accessToken);
previousYUserId === yUserId ? readExternalAccessTokenValue(previous) : "";
const cachedAccessToken = readExternalAccessTokenValue(cached);
const fallbackAccessToken = previousAccessToken || cachedAccessToken;
if (fallbackAccessToken) {
next.accessToken = fallbackAccessToken;
writeExternalAccessTokenFields(next, fallbackAccessToken);
} else {
delete next.accessToken;
deleteExternalAccessTokenFields(next);
}
return next;
}
@@ -121,27 +173,61 @@ function buildExternalUserInfo(
export async function loginByYUserId(
yUserId: string,
token?: string | null,
access_token?: string | null,
): Promise<void> {
if (token) {
authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token);
}
const requestUrl = `${CONSUMER_URL}/cop/userDetailByYuserId`;
logYUserLogin("request", {
requestUrl,
yUserId,
hasToolsToken: Boolean(token),
queryAccessToken: maskToken(normalizeExternalAccessToken(access_token)),
});
const res = (await requestCrudService.get(
`${CONSUMER_URL}/cop/userDetailByYuserId`,
requestUrl,
{ yuserId: yUserId },
)) as UserDetailResponse;
// consumer 返回 { accessToken, gateDepartName, typeName, userName }。
// 一并存入路由参数 yUserId,使「应用管理」里 source=yUserId 的路由参数能取到(与
// source=userName/gateDepartName/typeName 直接取接口值统一走 userInfo[source])。
// 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的
// access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。
// 注意:接口返回的 accessToken 优先;登录 URL 的 access_token 只在接口值为空时兜底。
// The consumer API may return accessToken. URL access_token is only used as
// a fallback when that API token is empty.
const result = res.data ?? {};
logYUserLogin("response", {
keys: Object.keys(result),
id: result.id,
userId: result.userId,
user_id: result.user_id,
userName: result.userName,
gateDepartName: result.gateDepartName,
typeName: result.typeName,
access_token: maskToken(normalizeExternalAccessToken(result.access_token)),
accessToken: maskToken(normalizeExternalAccessToken(result.accessToken)),
});
const previous = readExternalUserInfo();
const cached = readCachedYUserInfo(yUserId);
const next = buildExternalUserInfo(yUserId, result, previous, cached);
const next = buildExternalUserInfo(yUserId, result, access_token, previous, cached);
const externalUserId = normalizeExternalUserId(next.userId);
if (externalUserId) {
authStorageSet(EXTERNAL_USER_ID_STORAGE_KEY, externalUserId);
}
const externalAccessToken = readExternalAccessTokenValue(next);
if (externalAccessToken) {
writeStoredExternalAccessToken(externalAccessToken);
}
authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(next));
writeCachedYUserInfo(yUserId, next);
logYUserLogin("stored", {
userId: externalUserId,
topLevelAccessToken: maskToken(readStoredExternalAccessTokenValue()),
userInfo: summarizeUserInfo(next),
});
}
export function persistToolsToken(token: string): void {
@@ -149,17 +235,18 @@ export function persistToolsToken(token: string): void {
}
/**
* 把内网登录路由参数 access_token 合并写入 userInfo.accessToken(覆盖接口返回的同名值)。
* 把内网登录路由参数 access_token 合并写入 userInfo.access_token / userInfo.accessToken。
* 据此拼参的有:XK/PG 中任务 iframe(XK 用 accessToken、PG 用 access_token,二者均读
* external-links.getAccessToken() = userInfo.accessToken);以及「应用管理」里
* source=accessToken 的轻应用路由参数(resolveRouteParamValue 取 userInfo.accessToken)。
* external-links.getAccessToken());以及「应用管理」里 source=access_token / accessToken
* 的轻应用路由参数(resolveRouteParamValue 统一读两种字段)。
* 用合并而非覆盖整对象,避免抹掉 loginByYUserId 从 consumer 拿到的用户信息。
*/
export function persistAccessToken(token: string): void {
const trimmed = normalizeExternalAccessToken(token);
if (!trimmed) return;
const info = readExternalUserInfo();
info.accessToken = trimmed;
writeExternalAccessTokenFields(info, trimmed);
writeStoredExternalAccessToken(trimmed);
authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info));
const yUserId = readStoredYUserId(info);
if (yUserId) {
@@ -1,7 +1,7 @@
/** Strategy 模块外部 iframe / 嵌入页链接(来自 Vite 环境变量) */
import { authStorageGet } from "@/core/auth/scoped-storage";
import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
import { readCurrentExternalAccessTokenValue } from "@/core/auth/external-access-token";
function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/, "");
@@ -44,7 +44,7 @@ function readUserInfo(): Record<string, unknown> {
/** 当前登录态 accessToken(无则空串)。 */
export function getAccessToken(): string {
return normalizeExternalAccessToken(readUserInfo().accessToken);
return readCurrentExternalAccessTokenValue(readUserInfo());
}
/**
@@ -17,6 +17,7 @@ import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext';
import { ResearchTemplatesPanel } from './ResearchTemplatesPanel';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
@@ -436,6 +437,8 @@ export function AIWritingForm({
</Button>
</div>
<ResearchTemplatesPanel />
{!hideAdvancedFields && (
<>
<div className="flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3">
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, CloudUpload, PanelRightClose, Pause, Play, Plus, RotateCcw, Settings2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
@@ -73,11 +73,13 @@ export function AIWritingPanel({
initialNotebookId,
initialDocumentIds,
studioToken,
headerActionsBeforeCollapse,
}: {
hideAdvancedFields?: boolean;
initialNotebookId?: number;
initialDocumentIds?: number[];
studioToken?: string;
headerActionsBeforeCollapse?: ReactNode;
} = {}) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@@ -429,6 +431,7 @@ export function AIWritingPanel({
onRename={rename}
onRefresh={refresh}
/>
{headerActionsBeforeCollapse}
{/* 收起写作配置:仅在 idle 配置阶段、表单展开时出现(与 AIWritingPage 的整块收起联动) */}
{writingForm && isIdle && !isHistoryMode && writingForm.formExpanded && (
<Button
@@ -1,29 +1,19 @@
"use client";
import {
BarChart3,
BookOpen,
Eye,
FileText,
Globe2,
History,
LayoutGrid,
Loader2,
Newspaper,
PanelLeftClose,
Pencil,
Plus,
RefreshCw,
Search,
Settings2,
SlidersHorizontal,
Sparkles,
Trash2,
TrendingUp,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
@@ -38,64 +28,11 @@ import { createSearchSpace } from "@/core/studio/api";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { cn } from "@/lib/utils";
import { useResearchTemplates } from "../../hooks/useResearchTemplates";
import { useAIWritingSessions } from "../../hooks/useAIWritingSessions";
import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
import type { AIWritingSessionSummary } from "../../api/ai-writing-sessions";
import { useWritingForm } from "../../contexts/WritingFormContext";
import { useAIWriting } from "../../contexts/AIWritingContext";
import { useAIWritingHistory } from "../../contexts/AIWritingHistoryContext";
import { formatRelative } from "./AIWritingHistoryDropdown";
type TemplateVisual = {
Icon: LucideIcon;
iconClass: string;
accentClass: string;
};
const TEMPLATE_VISUALS: TemplateVisual[] = [
{
Icon: Globe2,
iconClass: "bg-blue-50 text-blue-600",
accentClass: "bg-blue-500",
},
{
Icon: Newspaper,
iconClass: "bg-amber-50 text-amber-600",
accentClass: "bg-amber-500",
},
{
Icon: Sparkles,
iconClass: "bg-violet-50 text-violet-600",
accentClass: "bg-violet-500",
},
{
Icon: TrendingUp,
iconClass: "bg-rose-50 text-rose-600",
accentClass: "bg-rose-500",
},
{
Icon: BookOpen,
iconClass: "bg-emerald-50 text-emerald-600",
accentClass: "bg-emerald-500",
},
{
Icon: BarChart3,
iconClass: "bg-cyan-50 text-cyan-600",
accentClass: "bg-cyan-500",
},
];
function getTemplateVisual(tpl: ResearchTemplate, index: number): TemplateVisual {
const text = `${tpl.name} ${tpl.description ?? ""} ${tpl.article_type_key ?? ""}`;
if (/网摘|网络|热点|摘/.test(text)) return TEMPLATE_VISUALS[0];
if (/周报|周刊|日报|月报/.test(text)) return TEMPLATE_VISUALS[1];
if (/要讯|资讯|快讯|简报/.test(text)) return TEMPLATE_VISUALS[2];
if (/行业|分析|市场|竞争/.test(text)) return TEMPLATE_VISUALS[3];
if (/政策|解读|法规/.test(text)) return TEMPLATE_VISUALS[4];
return TEMPLATE_VISUALS[index % TEMPLATE_VISUALS.length];
}
// 历史记录状态药丸样式(对齐多智能体会商的历史记录观感)。
// 注:`in_progress` 不再转圈显示「进行中」——刷新=重置后这些会话已无任何活进程,
// 转圈会误导用户以为还在跑;统一显示为中性、不动的「未完成」。真正还在跑的是 `background`
@@ -237,25 +174,11 @@ export function ResearchSidebar({
/** 收起侧栏(由页面承载收起态)。 */
onCollapse?: () => void;
}) {
const navigate = useNavigate();
const { templates, refresh } = useResearchTemplates();
const { startWriting } = useAIWriting();
const {
notebooks,
isLoading: notebooksLoading,
isError: notebooksError,
} = useStudioNotebooks();
const {
setIntent,
setArticleType,
setOutlineText,
setShowOutline,
setMaterialSource,
setWordCountInput,
setAudience,
setStrictMode,
setFormExpanded,
} = useWritingForm();
const queryClient = useQueryClient();
// 「历史记录」列表(左下角,样式参考多智能体会商的历史记录):自带一份会话列表,
@@ -283,26 +206,11 @@ export function ResearchSidebar({
window.dispatchEvent(new CustomEvent("ai-writing:show-progress", { detail: session }));
};
const [templateSearch, setTemplateSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
useEffect(() => {
void refresh();
}, [refresh]);
const filteredTemplates = useMemo(() => {
const keyword = templateSearch.trim().toLowerCase();
if (!keyword) return templates;
return templates.filter((tpl) =>
[tpl.name, tpl.description, tpl.article_type_key]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword)),
);
}, [templates, templateSearch]);
const handleCreate = async () => {
const trimmed = name.trim();
if (!trimmed) return;
@@ -323,39 +231,6 @@ export function ResearchSidebar({
}
};
const handleTemplateClick = (tpl: ResearchTemplate) => {
const intent = tpl.topic?.trim() ?? "";
const articleType = tpl.article_type_key || "analysis";
const outline = tpl.outline?.trim() || "";
const audience = tpl.audience?.trim() || "普通读者";
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
const writingMode = tpl.strict_mode ? "strict" : "loose";
if (intent) setIntent(intent);
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
if (outline) {
setOutlineText(outline);
setShowOutline(true);
}
setMaterialSource(tpl.material_source);
setWordCountInput(String(wordCount));
if (tpl.audience?.trim()) setAudience(audience);
setStrictMode(tpl.strict_mode);
setFormExpanded(true);
if (!intent) return;
void startWriting({
userIntent: intent,
userOutline: outline || undefined,
articleType,
targetAudience: audience,
wordCountTarget: wordCount,
keywordCount: 1,
writingMode,
materialSource: tpl.material_source,
});
};
return (
<aside className="ai-research-sidebar flex h-full w-[320px] shrink-0 flex-col overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)]">
{onCollapse && (
@@ -373,91 +248,7 @@ export function ResearchSidebar({
</div>
)}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4">
<section className="mb-5">
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<button
type="button"
onClick={() => navigate("/page/canvas/research-templates")}
title="配置研究模板"
aria-label="配置研究模板"
className="group flex items-center gap-2 rounded-lg text-[16px] font-bold text-slate-500 transition-colors hover:text-slate-800"
>
<SlidersHorizontal className="size-4 text-slate-400" />
<span>研究模板</span>
<Settings2 className="size-3.5 text-slate-300 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
<div className="relative w-[150px]">
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={templateSearch}
onChange={(event) => setTemplateSearch(event.target.value)}
placeholder="搜索写作模板..."
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50/80 pl-8 pr-3 text-[12px] text-slate-600 outline-none transition focus:border-blue-200 focus:bg-white focus:ring-2 focus:ring-blue-100"
/>
</div>
</div>
{templates.length === 0 ? (
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
暂无研究模板,点击标题配置
</p>
) : filteredTemplates.length === 0 ? (
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
暂无匹配模板
</p>
) : (
<div className="space-y-3">
{filteredTemplates.map((tpl, index) => {
const visual = getTemplateVisual(tpl, index);
const Icon = visual.Icon;
return (
<button
key={tpl.id}
type="button"
onClick={() => handleTemplateClick(tpl)}
title={tpl.description ?? tpl.name}
className="group relative flex w-full items-start gap-3 overflow-hidden rounded-[5px] bg-white px-4 py-4 text-left shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(30,64,175,0.08)] active:translate-y-0"
>
<span
className={cn(
"flex size-12 shrink-0 items-center justify-center rounded-xl transition-colors",
visual.iconClass,
)}
>
<Icon className="size-6 stroke-[2.2]" />
</span>
<span className="min-w-0 flex-1 pt-0.5">
<span className="block truncate text-[16px] font-bold leading-5 text-slate-900 transition-colors group-hover:text-blue-700">
{tpl.name}
</span>
{tpl.description && (
<span
className="mt-1.5 overflow-hidden text-[13px] leading-6 text-slate-500"
style={{
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
}}
>
{tpl.description}
</span>
)}
</span>
<span
className={cn(
"absolute bottom-4 left-0 top-4 w-1 rounded-r-full opacity-0 transition-opacity group-hover:opacity-100",
visual.accentClass,
)}
/>
</button>
);
})}
</div>
)}
</section>
<section className="flex flex-col border-t border-slate-100 pt-5">
<section className="flex flex-col">
<div className="mb-3 flex items-center justify-between px-2">
<h2 className="text-[15px] font-bold tracking-wide text-slate-400">
笔记本
@@ -0,0 +1,238 @@
"use client";
import {
BarChart3,
BookOpen,
Globe2,
Newspaper,
Search,
Settings2,
SlidersHorizontal,
Sparkles,
TrendingUp,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { cn } from "@/lib/utils";
import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
import { useAIWriting } from "../../contexts/AIWritingContext";
import { useWritingForm } from "../../contexts/WritingFormContext";
import { useResearchTemplates } from "../../hooks/useResearchTemplates";
type TemplateVisual = {
Icon: LucideIcon;
iconClass: string;
accentClass: string;
};
const TEMPLATE_VISUALS: TemplateVisual[] = [
{
Icon: Globe2,
iconClass: "bg-blue-50 text-blue-600",
accentClass: "bg-blue-500",
},
{
Icon: Newspaper,
iconClass: "bg-amber-50 text-amber-600",
accentClass: "bg-amber-500",
},
{
Icon: Sparkles,
iconClass: "bg-violet-50 text-violet-600",
accentClass: "bg-violet-500",
},
{
Icon: TrendingUp,
iconClass: "bg-rose-50 text-rose-600",
accentClass: "bg-rose-500",
},
{
Icon: BookOpen,
iconClass: "bg-emerald-50 text-emerald-600",
accentClass: "bg-emerald-500",
},
{
Icon: BarChart3,
iconClass: "bg-cyan-50 text-cyan-600",
accentClass: "bg-cyan-500",
},
];
function getTemplateVisual(
tpl: ResearchTemplate,
index: number,
): TemplateVisual {
const text = `${tpl.name} ${tpl.description ?? ""} ${tpl.article_type_key ?? ""}`;
if (/网摘|网络|热点|摘/.test(text)) return TEMPLATE_VISUALS[0];
if (/周报|周刊|日报|月报/.test(text)) return TEMPLATE_VISUALS[1];
if (/要讯|资讯|快讯|简报/.test(text)) return TEMPLATE_VISUALS[2];
if (/行业|分析|市场|竞争/.test(text)) return TEMPLATE_VISUALS[3];
if (/政策|解读|法规/.test(text)) return TEMPLATE_VISUALS[4];
return TEMPLATE_VISUALS[index % TEMPLATE_VISUALS.length];
}
export function ResearchTemplatesPanel({
className,
}: {
className?: string;
}) {
const navigate = useNavigate();
const { templates, refresh } = useResearchTemplates();
const { startWriting } = useAIWriting();
const {
setIntent,
setArticleType,
setOutlineText,
setShowOutline,
setMaterialSource,
setWordCountInput,
setAudience,
setStrictMode,
setFormExpanded,
} = useWritingForm();
const [templateSearch, setTemplateSearch] = useState("");
useEffect(() => {
void refresh();
}, [refresh]);
const filteredTemplates = useMemo(() => {
const keyword = templateSearch.trim().toLowerCase();
if (!keyword) return templates;
return templates.filter((tpl) =>
[tpl.name, tpl.description, tpl.article_type_key]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword)),
);
}, [templates, templateSearch]);
const handleTemplateClick = (tpl: ResearchTemplate) => {
const intent = tpl.topic?.trim() ?? "";
const articleType = tpl.article_type_key || "analysis";
const outline = tpl.outline?.trim() || "";
const audience = tpl.audience?.trim() || "普通读者";
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
const writingMode = tpl.strict_mode ? "strict" : "loose";
if (intent) setIntent(intent);
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
if (outline) {
setOutlineText(outline);
setShowOutline(true);
}
setMaterialSource(tpl.material_source);
setWordCountInput(String(wordCount));
if (tpl.audience?.trim()) setAudience(audience);
setStrictMode(tpl.strict_mode);
setFormExpanded(true);
if (!intent) return;
void startWriting({
userIntent: intent,
userOutline: outline || undefined,
articleType,
targetAudience: audience,
wordCountTarget: wordCount,
keywordCount: 1,
writingMode,
materialSource: tpl.material_source,
});
};
return (
<section
className={cn(
"rounded-2xl border border-[#ece5e4] bg-card p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
className,
)}
>
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={() => navigate("/page/canvas/research-templates")}
title="配置研究模板"
aria-label="配置研究模板"
className="group flex items-center gap-2 rounded-lg text-[16px] font-bold text-slate-600 transition-colors hover:text-slate-900 dark:text-white"
>
<SlidersHorizontal className="size-4 text-slate-400 dark:text-white/70" />
<span>研究模板</span>
<Settings2 className="size-3.5 text-slate-300 opacity-0 transition-opacity group-hover:opacity-100 dark:text-white/60" />
</button>
<div className="relative min-w-0 flex-1 sm:max-w-[180px]">
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={templateSearch}
onChange={(event) => setTemplateSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") event.preventDefault();
}}
placeholder="搜索写作模板..."
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50/80 pl-8 pr-3 text-[12px] text-slate-600 outline-none transition focus:border-blue-200 focus:bg-white focus:ring-2 focus:ring-blue-100 dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]"
/>
</div>
</div>
{templates.length === 0 ? (
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
暂无研究模板,点击标题配置
</p>
) : filteredTemplates.length === 0 ? (
<p className="rounded-xl border border-dashed border-slate-200 px-3 py-5 text-center text-xs text-slate-400">
暂无匹配模板
</p>
) : (
<div className="space-y-3">
{filteredTemplates.map((tpl, index) => {
const visual = getTemplateVisual(tpl, index);
const Icon = visual.Icon;
return (
<button
key={tpl.id}
type="button"
onClick={() => handleTemplateClick(tpl)}
title={tpl.description ?? tpl.name}
className="group relative flex w-full items-start gap-3 overflow-hidden rounded-[5px] bg-white px-4 py-4 text-left shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(30,64,175,0.08)] active:translate-y-0 dark:bg-[#032650]"
>
<span
className={cn(
"flex size-12 shrink-0 items-center justify-center rounded-xl transition-colors",
visual.iconClass,
)}
>
<Icon className="size-6 stroke-[2.2]" />
</span>
<span className="min-w-0 flex-1 pt-0.5">
<span className="block truncate text-[16px] font-bold leading-5 text-slate-900 transition-colors group-hover:text-blue-700 dark:text-white">
{tpl.name}
</span>
{tpl.description && (
<span
className="mt-1.5 overflow-hidden text-[13px] leading-6 text-slate-500 dark:text-[#AACEFF]/75"
style={{
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
}}
>
{tpl.description}
</span>
)}
</span>
<span
className={cn(
"absolute bottom-4 left-0 top-4 w-1 rounded-r-full opacity-0 transition-opacity group-hover:opacity-100",
visual.accentClass,
)}
/>
</button>
);
})}
</div>
)}
</section>
);
}
@@ -81,7 +81,7 @@
* -d '{"retention_days": 0}'
* ──────────────────────────────────────────────────────────────────────────
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
ArrowLeft,
History,
@@ -113,6 +113,10 @@ import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage';
import StudioNotebooksPage from '@/pages/StudioNotebooksPage';
import AgentChatPage from '@/pages/AgentChatPage';
import { ChatRuntime } from '@/pages/WorkspaceRoutes';
import {
LayoutModeSwitcher,
ThemeSwitcher,
} from '@/components/workspace/appearance-controls';
import {
AgentModuleSidebar,
useAgentModuleSidebarData,
@@ -137,10 +141,20 @@ const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft';
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
function AIWritingCompactAppearanceActions() {
return (
<div className="flex items-center gap-1">
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
</div>
);
}
function useAIWritingRouteBase() {
const { pathname } = useLocation();
return useMemo(() => {
@@ -170,7 +184,15 @@ function useAIWritingAgentRouteState(base: string) {
function AIWritingAgentDefaultRedirect() {
const { groups, isLoading } = useAgentModuleSidebarData(AI_WRITING_AGENT_MODULE_KEY);
const firstAgent = groups.flatMap((group) => group.agents)[0];
const agents = groups.flatMap((group) => group.agents);
const lastAgentId = (() => {
try {
return sessionStorage.getItem(AI_WRITING_LAST_AGENT_KEY);
} catch {
return null;
}
})();
const targetAgent = agents.find((agent) => agent.id === lastAgentId) ?? agents[0];
if (isLoading) {
return (
@@ -180,7 +202,7 @@ function AIWritingAgentDefaultRedirect() {
);
}
if (!firstAgent) {
if (!targetAgent) {
return (
<div className="flex size-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
暂无可用智能体,请先在模块设置中配置或创建智能体。
@@ -188,24 +210,44 @@ function AIWritingAgentDefaultRedirect() {
);
}
return <Navigate to={`agents/${firstAgent.id}/chats/new`} replace />;
return <Navigate to={`agents/${targetAgent.id}/chats/new`} replace />;
}
function AIWritingAgentChatRoute() {
function AIWritingAgentChatRoute({
compactAppearanceActions,
}: {
compactAppearanceActions?: ReactNode;
}) {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage key={agent_id} />
<AgentChatPage
key={agent_id}
compactAppearanceActions={compactAppearanceActions}
/>
</ChatRuntime>
);
}
function AIWritingAgentRoutes({ base }: { base: string }) {
function AIWritingAgentRoutes({
base,
compactAppearanceActions,
}: {
base: string;
compactAppearanceActions?: ReactNode;
}) {
return (
<RouteBaseProvider value={base}>
<Routes>
<Route index element={<AIWritingAgentDefaultRedirect />} />
<Route path="agents/:agent_id/chats/:thread_id" element={<AIWritingAgentChatRoute />} />
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<AIWritingAgentChatRoute
compactAppearanceActions={compactAppearanceActions}
/>
}
/>
</Routes>
</RouteBaseProvider>
);
@@ -220,11 +262,26 @@ interface NotebookProps {
studioToken?: string;
}
function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
function WritingFormPanel({
hideAdvancedFields,
initialNotebookId,
initialDocumentIds,
studioToken,
headerActionsBeforeCollapse,
}: {
hideAdvancedFields: boolean;
headerActionsBeforeCollapse?: ReactNode;
} & NotebookProps) {
return (
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
<AIWritingPanel
hideAdvancedFields={hideAdvancedFields}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
headerActionsBeforeCollapse={headerActionsBeforeCollapse}
/>
</div>
);
}
@@ -234,7 +291,16 @@ function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocume
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
*/
function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
function AIWritingLayout({
hideAdvancedFields,
initialNotebookId,
initialDocumentIds,
studioToken,
compactAppearanceActions,
}: {
hideAdvancedFields: boolean;
compactAppearanceActions?: ReactNode;
} & NotebookProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
@@ -262,19 +328,28 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px] border-l' : 'w-0 min-w-0',
)}
>
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
<WritingFormPanel
hideAdvancedFields={hideAdvancedFields}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
headerActionsBeforeCollapse={compactAppearanceActions}
/>
</div>
{/* 收起态:右上角浮动展开图标按钮(与展开态头部的收起按钮位置呼应) */}
{!formExpanded && (
<button
type="button"
onClick={() => setFormExpanded(true)}
title="展开写作配置"
className="absolute right-3 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
>
<PanelRightOpen className="size-4" />
</button>
<div className="absolute right-3 top-2 z-10 flex items-center gap-1">
{compactAppearanceActions}
<button
type="button"
onClick={() => setFormExpanded(true)}
title="展开写作配置"
className="flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
>
<PanelRightOpen className="size-4" />
</button>
</div>
)}
</div>
);
@@ -322,7 +397,16 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
* 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态:
* 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。
*/
function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
function AIWritingBody({
hideAdvancedFields,
initialNotebookId,
initialDocumentIds,
studioToken,
compactChrome = false,
}: {
hideAdvancedFields: boolean;
compactChrome?: boolean;
} & NotebookProps) {
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
const location = useLocation();
@@ -354,6 +438,9 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
// 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。
const showSidebar =
!studioToken && (state.status === 'idle' || isHistoryMode || agentsActive);
const compactAppearanceActions = compactChrome ? (
<AIWritingCompactAppearanceActions />
) : null;
return (
<div className="flex-1 min-h-0 overflow-hidden flex">
@@ -459,7 +546,13 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
<div className="relative flex-1 min-h-0 overflow-hidden">
<div className={cn('absolute inset-0', (overlayActive || agentsActive) && 'hidden')}>
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
<AIWritingLayout
hideAdvancedFields={hideAdvancedFields}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
compactAppearanceActions={compactAppearanceActions}
/>
</div>
{overlayActive && !agentsActive && (
<div className="absolute inset-0 flex flex-col bg-background">
@@ -500,7 +593,10 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
)}
{agentsActive && (
<div className="absolute inset-0 bg-background">
<AIWritingAgentRoutes base={routeBase} />
<AIWritingAgentRoutes
base={routeBase}
compactAppearanceActions={compactAppearanceActions}
/>
</div>
)}
</div>
@@ -521,11 +617,13 @@ export default function AIWritingPage({
initialNotebookId,
initialDocumentIds,
studioToken,
compactChrome = false,
}: {
hideAdvancedFields?: boolean;
initialNotebookId?: number;
initialDocumentIds?: number[];
studioToken?: string;
compactChrome?: boolean;
} = {}) {
return (
<CanvasThreadProvider>
@@ -545,6 +643,7 @@ export default function AIWritingPage({
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
compactChrome={compactChrome}
/>
</div>
</TooltipProvider>
+5 -1
View File
@@ -4,7 +4,7 @@ import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
@@ -73,6 +73,7 @@ export default function AgentChatPage({
taskBind,
autoStartWhenPurposeEmpty,
showAgentList,
compactAppearanceActions,
}: {
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
@@ -93,6 +94,8 @@ export default function AgentChatPage({
/** 是否在最左侧展示「智能体列表」栏(顶部下拉切换 我的/广场,点击进入该智能体对话)。
* 仅独立的智能体问答整页开启;任务工作区 / 模块页 / 嵌入态不传(各有自己的左栏)。 */
showAgentList?: boolean;
/** 简洁模式下,挂到右侧智能体信息面板收起按钮左侧的外观控件。 */
compactAppearanceActions?: ReactNode;
} = {}) {
const routeBase = useRouteBase();
const { t } = useI18n();
@@ -611,6 +614,7 @@ export default function AgentChatPage({
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
defaultCollapsed={taskCtxOn}
showRecentChats={!taskCtxOn}
compactAppearanceActions={compactAppearanceActions}
/>
</div>
+19 -2
View File
@@ -15,6 +15,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import {
LayoutModeSwitcher,
ThemeSwitcher,
} from "@/components/workspace/appearance-controls";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -40,7 +44,11 @@ import { cn } from "@/lib/utils";
const PAGE_SIZE = 20;
export default function ChatsPage() {
export default function ChatsPage({
compactChrome = false,
}: {
compactChrome?: boolean;
}) {
const pathOfThread = usePathOfThread();
const { t } = useI18n();
const { toggle: togglePin } = useToggleThreadPin();
@@ -171,7 +179,16 @@ export default function ChatsPage() {
return (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceHeader
rightActions={
compactChrome ? (
<div className="flex items-center gap-1">
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
</div>
) : null
}
/>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
@@ -89,9 +89,9 @@ const emptyForm = (): FormState => ({
});
const newParam = (): LightAppRouteParam => ({
key: "",
key: "access_token",
paramType: "login",
source: "accessToken",
source: "access_token",
customValue: "",
themeValue: "red=red;blue=blue;dark-blue=dark-blue",
});
@@ -107,7 +107,7 @@ function formFromApp(app: LightApp): FormState {
routeParams: app.routeParams.map((p) => ({
key: p.key,
paramType: p.paramType,
source: p.source ?? "accessToken",
source: p.source ?? "access_token",
customValue: p.customValue ?? "",
themeValue: p.themeValue ?? "",
})),
@@ -118,7 +118,7 @@ function getParamSourceLabel(param: LightAppRouteParam): string {
if (param.paramType === "theme") {
return `主题参数: ${param.themeValue || ""}`;
}
const source = param.source || "accessToken";
const source = param.source || "access_token";
const option = LOGIN_SOURCE_OPTIONS.find((o) => o.value === source);
if (source === "other") {
return `登录参数: ${param.customValue || ""}`;
@@ -977,7 +977,7 @@ export default function LightAppManagementPage() {
onChange={(v) =>
patchParam(idx, {
paramType: v as "login" | "theme",
source: v === "login" ? (p.source || "accessToken") : undefined,
source: v === "login" ? (p.source || "access_token") : undefined,
})
}
/>
@@ -988,7 +988,7 @@ export default function LightAppManagementPage() {
<Select
className="w-full"
size="small"
value={p.source || "accessToken"}
value={p.source || "access_token"}
options={LOGIN_SOURCE_OPTIONS}
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) => patchParam(idx, { source: v as string })}
@@ -1112,7 +1112,7 @@ function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
.map((p) => {
const base: LightAppRouteParam = { key: p.key.trim(), paramType: p.paramType };
if (p.paramType === "login") {
base.source = p.source || "accessToken";
base.source = p.source || "access_token";
if (base.source === "other") {
base.customValue = (p.customValue ?? "").trim();
}
+100 -9
View File
@@ -35,6 +35,35 @@ import { fetchYLogConfig } from '@/core/log/y-log-config';
import { persistYLogToken } from '@/core/log/y-log-token';
import { persistLoginCredentials } from '@/core/auth/login-credentials';
import { OptType, writeYLog } from '@/core/log/y-log';
import { authStorageGet } from '@/core/auth/scoped-storage';
/** 令牌脱敏:日志里只露头尾,避免完整令牌泄漏。 */
function maskLoginToken(value: string): string {
if (!value) return '';
if (value.length <= 10) return `${value.slice(0, 2)}***(${value.length})`;
return `${value.slice(0, 6)}...${value.slice(-4)}(${value.length})`;
}
/** 读取已落地的 userInfo(脱敏 access token 后)供登录后排查。 */
function summarizeStoredUserInfo(): Record<string, unknown> | null {
try {
const raw = authStorageGet('userInfo');
if (!raw) return null;
const info = JSON.parse(raw) as Record<string, unknown>;
return {
keys: Object.keys(info),
yUserId: info.yUserId,
userId: info.userId,
userName: info.userName,
gateDepartName: info.gateDepartName,
typeName: info.typeName,
access_token: maskLoginToken(String(info.access_token ?? '')),
accessToken: maskLoginToken(String(info.accessToken ?? '')),
};
} catch {
return null;
}
}
const DEFAULT_USERNAME = 'guest';
const IFRAME_LOGIN_SOURCE = 'iframe';
@@ -75,7 +104,7 @@ export default function LoginPage() {
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
const toolToken = searchParams.get('token')?.trim() ?? '';
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
const accessToken = searchParams.get('access_token')?.trim() ?? '';
const access_token = searchParams.get('access_token')?.trim() ?? '';
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
const gatePassword = searchParams.get('password') ?? '';
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
@@ -171,19 +200,68 @@ export default function LoginPage() {
async function runLogin() {
setStatus('loading');
setErrorMessage('');
// ── 调试:登录路由参数全打印 ──────────────────────────────────────────────
// token / yUserId / 用户名 / ticket 任一方式都打印(令牌脱敏),方便排查
// “userInfo 取不到值 / 传值不对” 一类问题。`embed=0`/正常态也照打。
const loginMethod = authToken
? 'token(authToken)'
: yUserId
? 'yUserId'
: onlineTicket
? 'onlineTicket'
: toolToken
? 'toolsToken'
: 'username';
console.info('[login] route params', {
method: loginMethod,
pathUsername: resolvedUsername,
yUserId,
access_token: maskLoginToken(access_token),
toolToken: maskLoginToken(toolToken),
authToken: maskLoginToken(authToken),
gatePassword: gatePassword ? maskLoginToken(gatePassword) : '',
onlineTicket: onlineTicket ? maskLoginToken(onlineTicket) : '',
taskId,
goPath,
redirect: redirectTo,
isEmbed,
rawSearch: searchParams.toString(),
});
try {
let auth;
let loginUserName = '';
// 外部用户信息(yUserId → consumer 用户详情)与登录鉴权方式**解耦**:只要 URL
// 带 yUserId 就拉一次 userDetailByYuserId,把 yUserId/userName/gateDepartName/
// typeName/accessToken 落进 userInfo。之前 token(authToken) 登录会短路掉这一步,
// 导致 token 登录后 userInfo 残缺(只剩 userName)、iframe/轻应用拿不到这些值。
if (yUserId) {
if (authToken) {
// token 登录下 consumer 失败不应阻断登录(仅影响 userInfo 的富信息)。
try {
await loginByYUserId(yUserId, toolToken || null, access_token || null);
} catch (error) {
console.warn('[login] loginByYUserId failed under token login (non-fatal)', error);
}
} else {
await loginByYUserId(yUserId, toolToken || null, access_token || null);
}
}
if (authToken) {
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
auth = await loginByToken(authToken);
// 让 token 登录也写入正确用户名(后端 getTokenInfo 解析出的原始登录名)。
// 若上面 yUserId 已从 consumer 落了 userName,则 setStoredUserName 不会覆盖它。
loginUserName = auth.username?.trim() ?? '';
} else {
let deerflowUsername = resolvedUsername;
if (onlineTicket) {
// yUserId 优先(已在上面落好 userInfo + tools_token);其余方式仅在无 yUserId 时启用。
if (!yUserId && onlineTicket) {
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
} else if (yUserId) {
await loginByYUserId(yUserId, toolToken || null);
} else if (toolToken) {
} else if (!yUserId && toolToken) {
persistToolsToken(toolToken);
}
@@ -212,9 +290,22 @@ export default function LoginPage() {
}
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
setStoredUserName(loginUserName);
// 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
// XK/PG 中任务 iframe 据此取 accessToken
persistAccessToken(accessToken);
// 非 yUserId 登录仍可直接持久化 access_token;yUserId 登录已在 loginByYUserId 内按
// “接口 accessToken 优先,URL access_token 仅兜底”的规则处理。
if (!yUserId) {
persistAccessToken(access_token);
}
// ── 调试:登录落地结果(auth + 实际写入的 userInfo,access token 已脱敏) ──
console.info('[login] stored result', {
method: loginMethod,
user_id: auth.user_id,
email: auth.email,
authUsername: auth.username,
loginUserName,
userInfo: summarizeStoredUserInfo(),
});
// 嵌入模式不写 sidebarEnabled(localStorage 的「简洁/全量」偏好会跨标签污染正常页);
// 去 chrome 由按标签页的嵌入会话(isEmbedActive)驱动,见 PageLayout。
if (!isEmbed) persistSidebarEnabled(sidebarEnabled);
@@ -273,7 +364,7 @@ export default function LoginPage() {
sidebarEnabled,
yUserId,
toolToken,
accessToken,
access_token,
onlineTicket,
authToken,
gatePassword,
+19 -2
View File
@@ -43,6 +43,10 @@ import {
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import {
LayoutModeSwitcher,
ThemeSwitcher,
} from "@/components/workspace/appearance-controls";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
@@ -144,7 +148,11 @@ const SCHEDULED_TASK_TEXTAREA_CLASS =
const DESCRIPTION_TEXTAREA_ROWS = 4;
const PROMPT_TEXTAREA_ROWS = 10;
export default function ScheduledTasksPage() {
export default function ScheduledTasksPage({
compactChrome = false,
}: {
compactChrome?: boolean;
}) {
const queryClient = useQueryClient();
const { theme } = useTheme();
const darkToastOpts =
@@ -490,7 +498,16 @@ export default function ScheduledTasksPage() {
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceHeader
rightActions={
compactChrome ? (
<div className="flex items-center gap-1">
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
</div>
) : null
}
/>
<WorkspaceBody>
<div className="grid size-full min-h-0 grid-cols-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 p-4 xl:grid-cols-[420px_minmax(0,1fr)] xl:grid-rows-1">
<Card className="flex min-h-0 min-w-0 flex-col">
+1 -1
View File
@@ -124,7 +124,7 @@ export default function TaskButtonsPage() {
);
const addLoginParam = (id: string) =>
updateLoginParams(id, (ps) => [...ps, { key: "", source: "accessToken", customValue: "" }]);
updateLoginParams(id, (ps) => [...ps, { key: "access_token", source: "access_token", customValue: "" }]);
const patchLoginParam = (id: string, idx: number, patch: Partial<TaskButtonLoginParam>) =>
updateLoginParams(id, (ps) => ps.map((p, i) => (i === idx ? { ...p, ...patch } : p)));
+45 -6
View File
@@ -5,7 +5,12 @@ import { getStoredAuth } from '@/core/auth';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
import {
LayoutModeSwitcher,
ThemeSwitcher,
} from '@/components/workspace/appearance-controls';
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context';
@@ -73,6 +78,27 @@ function isInIframe(): boolean {
}
}
function shouldShowCompactTopActions(pathname: string): boolean {
return (
pathname === '/page/workspace/agents' ||
pathname === '/page/workspace/skills' ||
pathname === '/page/workspace/memory' ||
pathname === '/page/workspace/knowledge' ||
pathname.startsWith('/page/workspace/knowledge/')
);
}
function CompactWorkspaceTopActions() {
return (
<div className="z-[60] flex h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 bg-card/90 px-4 shadow-sm backdrop-blur-md">
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
<div className="bg-border/70 mx-1 h-6 w-px shrink-0" aria-hidden />
<UserIdentityBadge />
</div>
);
}
export function WorkspaceLayout({
children,
pageSidebarCollapsed,
@@ -99,7 +125,10 @@ export function WorkspaceLayout({
}) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const isGeneralQa = useLocation().pathname.startsWith("/page/workspace/chats");
const pathname = useLocation().pathname;
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
const showCompactTopActions =
headerHidden && shouldShowCompactTopActions(pathname);
const pageSidebarWidth = pageSidebarHidden
? '0rem'
@@ -137,7 +166,14 @@ export function WorkspaceLayout({
<SidebarInset
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
>
{children}
{showCompactTopActions ? (
<>
<CompactWorkspaceTopActions />
<div className="min-h-0 flex-1">{children}</div>
</>
) : (
children
)}
</SidebarInset>
<CommandPalette />
<Toaster position="top-center" />
@@ -223,7 +259,7 @@ export default function WorkspaceRoutes({
path="chats"
element={
<WorkspaceLayout {...layoutProps}>
<ChatsPage />
<ChatsPage compactChrome={headerHidden} />
</WorkspaceLayout>
}
/>
@@ -339,7 +375,10 @@ export default function WorkspaceRoutes({
path="ai-writing/*"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage hideAdvancedFields />
<AIWritingPage
hideAdvancedFields
compactChrome={headerHidden}
/>
</WorkspaceLayout>
}
/>
@@ -372,7 +411,7 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<RoundtablePlanningPage />
<RoundtablePlanningPage compactChrome={headerHidden} />
</ChatRuntime>
</WorkspaceLayout>
}
@@ -434,7 +473,7 @@ export default function WorkspaceRoutes({
path="scheduled/tasks"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTasksPage />
<ScheduledTasksPage compactChrome={headerHidden} />
</WorkspaceLayout>
}
/>
@@ -61,6 +61,10 @@ import {
import { useTheme } from "@/contexts/ThemeContext";
import { useArtifacts } from "@/components/workspace/artifacts";
import {
LayoutModeSwitcher,
ThemeSwitcher,
} from "@/components/workspace/appearance-controls";
import { useSidebarSafe } from "@/components/ui/sidebar";
import {
DropdownMenu,
@@ -282,7 +286,11 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
}
export default function RoundtablePlanningPage() {
export default function RoundtablePlanningPage({
compactChrome = false,
}: {
compactChrome?: boolean;
}) {
const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
const { watchJob } = useRoundtableJobNotifier();
@@ -2075,6 +2083,12 @@ export default function RoundtablePlanningPage() {
>
<Settings className="w-4 h-4" />
</button>
{compactChrome && (
<>
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
</>
)}
</div>
</header>
+15 -15
View File
@@ -8,21 +8,21 @@ import {
import { RouteBaseContext } from "@/core/route-base";
export function useRouter() {
const navigate = useNavigate();
const base = useContext(RouteBaseContext);
return useMemo(
() => ({
push: (href: string) =>
navigate(href.replace("/page/workspace", base)),
replace: (href: string) =>
navigate(href.replace("/page/workspace", base), { replace: true }),
back: () => navigate(-1),
forward: () => navigate(1),
refresh: () => window.location.reload(),
prefetch: async () => {},
}),
export function useRouter() {
const navigate = useNavigate();
const base = useContext(RouteBaseContext);
const resolveHref = (href: string) =>
href.startsWith(base) ? href : href.replace("/page/workspace", base);
return useMemo(
() => ({
push: (href: string) => navigate(resolveHref(href)),
replace: (href: string) => navigate(resolveHref(href), { replace: true }),
back: () => navigate(-1),
forward: () => navigate(1),
refresh: () => window.location.reload(),
prefetch: async () => {},
}),
[navigate, base],
);
}
@@ -49,7 +49,7 @@ interface ServerButton {
function toLoginParam(p: ServerLoginParam): TaskButtonLoginParam {
return {
key: p.key ?? "",
source: p.source || "accessToken",
source: p.source || "access_token",
customValue: p.custom_value ?? "",
};
}
@@ -28,6 +28,7 @@ import {
platformTypeChange,
} from './GeneralQA.mock';
import { requestCrudService } from '@/service/request-axios-service';
import { authStorageGet } from '@/core/auth/scoped-storage';
/**
* 通用问答组件
@@ -138,7 +139,7 @@ const GeneralQA: React.FC<{
const mockSingleRoundStoppedRef = useRef(false);
const mockMultiRoundStoppedRef = useRef(false);
const chatListRef = useRef<HTMLDivElement | null>(null);
const userId = useRef(localStorage.getItem('userId') || '387'); // 用户id
const userId = useRef(authStorageGet('userId') || '387'); // 用户id
const qaType = useRef('GENERAL'); // 问答类型
const chatDataId = useRef(); // 聊天窗口id
const compareChatId = useRef(); // 对比对话记录id
@@ -7,7 +7,7 @@
import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/light-apps";
import { authStorageGet } from "@/core/auth/scoped-storage";
import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
import { readCurrentExternalAccessTokenValue } from "@/core/auth/external-access-token";
/** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */
export const MOUNT_OPTIONS: { label: string; value: string }[] = [
@@ -29,7 +29,8 @@ export function mountLabel(value: string | null | undefined): string {
* userName / gateDepartName / typeName 取 consumer 接口 userDetailByYuserId 的返回值。
*/
export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
{ label: "accessToken", value: "accessToken" },
{ label: "access_token", value: "access_token" },
{ label: "accessToken(兼容旧配置)", value: "accessToken" },
{ label: "yUserId", value: "yUserId" },
{ label: "userName", value: "userName" },
{ label: "gateDepartName", value: "gateDepartName" },
@@ -143,8 +144,8 @@ export function resolveRouteParamValue(
}
if (param.source) {
const value = userInfo[param.source];
if (param.source === "accessToken") {
return normalizeExternalAccessToken(value);
if (param.source === "access_token" || param.source === "accessToken") {
return readCurrentExternalAccessTokenValue(userInfo);
}
return value == null ? "" : String(value);
}
@@ -157,15 +158,50 @@ export function resolveRouteParamValue(
* 兼容 hash 路由地址(如 `https://x#/route`)—— 管理员粘贴完整地址,参数自动追加在末尾,
* 落在 hash 路由的 query 上(与现有 XK/PG 页面 `?accessToken=` 拼接方式一致)。
*/
function appendQueryPairs(url: string, pairs: string[]): string {
if (pairs.length === 0) return url;
const hashIndex = url.indexOf("#");
if (hashIndex >= 0) {
const beforeHash = url.slice(0, hashIndex);
const hash = url.slice(hashIndex);
const sep = hash.includes("?") ? "&" : "?";
return `${beforeHash}${hash}${sep}${pairs.join("&")}`;
}
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}${pairs.join("&")}`;
}
function replaceAccessTokenParam(url: string, accessToken: string): { url: string; replaced: boolean } {
if (!accessToken) return { url, replaced: false };
let replaced = false;
const nextUrl = url.replace(/([?&]access_token)(?:=[^&#]*)?(?=[&#]|$)/g, (match) => {
replaced = true;
return `${match.split("=")[0]}=${encodeURIComponent(accessToken)}`;
});
return { url: nextUrl, replaced };
}
export function buildLightAppUrl(
app: Pick<LightApp, "url" | "routeParams">,
userInfo: Record<string, unknown> = readUserInfo(),
theme?: AppThemeColor,
): string {
const pairs = (app.routeParams ?? [])
.filter((p) => p.key)
.map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo, theme))}`);
if (pairs.length === 0) return app.url;
const sep = app.url.includes("?") ? "&" : "?";
return `${app.url}${sep}${pairs.join("&")}`;
const routeParams = app.routeParams ?? [];
const accessToken = readCurrentExternalAccessTokenValue(userInfo);
const tokenUrlResult = replaceAccessTokenParam(app.url, accessToken);
const pairs = routeParams
.filter((p) => (p.key ?? "").trim())
.filter((p) => (p.key ?? "").trim() !== "access_token" || !tokenUrlResult.replaced)
.map((p) => {
const key = (p.key ?? "").trim();
const value = key === "access_token" ? accessToken : resolveRouteParamValue(p, userInfo, theme);
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
});
const hasAccessTokenParam =
tokenUrlResult.replaced ||
routeParams.some((p) => (p.key ?? "").trim() === "access_token");
if (!hasAccessTokenParam && accessToken) {
pairs.push(`${encodeURIComponent("access_token")}=${encodeURIComponent(accessToken)}`);
}
return appendQueryPairs(tokenUrlResult.url, pairs);
}
@@ -349,7 +349,10 @@ async def _search_notebook(
results: list[Material] = []
mat_idx = 0
try:
async with httpx.AsyncClient(timeout=20) as client:
# trust_env=False:笔记后端是内网/本机地址,但模型走公网,部署常设
# HTTP_PROXY/HTTPS_PROXY;httpx 默认会把这个内网请求也走代理 → 不可达。
# 强制忽略代理 env、直连(与内网 ES 检索同款修法)。
async with httpx.AsyncClient(timeout=20, trust_env=False) as client:
for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]:
logger.info("[notebook-search] POST kw=%r body=%s", kw, {
"query": kw,
@@ -113,8 +113,19 @@ class IntranetSearchProvider(BaseSearchProvider):
"ok": False,
"count": 0,
}
# 排查用:每次真实调用都打一行请求明细(端点 / 知识库 / 检索词 / 超时),
# 内网复现时直接看后端日志即可知道"到底请求了哪个地址、用什么参数"。
logger.info(
"[intranet-search] POST endpoint=%s kb=%s timeout=%ss query=%r",
self.endpoint, self.knowledge_base_name, self.timeout, query,
)
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
# trust_env=False:**关键**。内网 ES 是内网地址,但本部署的模型走公网,
# 服务器通常设了 HTTP_PROXY/HTTPS_PROXY 出公网;httpx 默认 trust_env=True
# 会把这个内网请求也套用公网代理 → 代理到不了内网 IP → 连接被重置/空回复,
# 表现为「接口异常」。强制忽略代理 env、直连内网(与 multi_agent.py 的回环
# 调用同款修法)。
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
resp = await client.post(self.endpoint, json=payload)
call["http_status"] = getattr(resp, "status_code", None)
resp.raise_for_status()
@@ -122,23 +133,31 @@ class IntranetSearchProvider(BaseSearchProvider):
data = resp.json()
except httpx.HTTPStatusError as e:
# 非 2xx:接口有响应但状态码异常,把响应体一并暴露给前端
logger.warning("intranet search failed for query=%r: %s", query, e)
body_for_log = e.response.text if e.response is not None else ""
logger.warning(
"[intranet-search] HTTP error endpoint=%s query=%r status=%s body=%s",
self.endpoint, query, getattr(e.response, "status_code", None), _raw_snippet(body_for_log),
)
message = f"{type(e).__name__}: {e}"
self.errors.append(message)
body = e.response.text if e.response is not None else ""
call.update({
"status": "http_error",
"error": message,
"http_status": getattr(e.response, "status_code", None),
})
snippet = _raw_snippet(body)
snippet = _raw_snippet(body_for_log)
if snippet:
call["raw_response"] = snippet
self._record_raw(body)
self._record_raw(body_for_log)
return []
except Exception as e:
# 连接失败 / 超时 / JSON 解析失败等;JSON 解析失败时 raw_text 已是接口响应体
logger.warning("intranet search failed for query=%r: %s", query, e)
# 连接失败 / 超时 / JSON 解析失败等;JSON 解析失败时 raw_text 已是接口响应体。
# 打出异常类型,便于区分 ConnectError / ConnectTimeout / ReadTimeout /
# RemoteProtocolError(空回复) / JSONDecodeError(返回非 JSON) 等具体根因。
logger.warning(
"[intranet-search] request failed endpoint=%s query=%r error=%s: %s",
self.endpoint, query, type(e).__name__, e,
)
message = f"{type(e).__name__}: {e}"
self.errors.append(message)
call.update({"status": "error", "error": message})
@@ -187,6 +206,10 @@ class IntranetSearchProvider(BaseSearchProvider):
self._record_raw(raw_text)
return []
logger.info(
"[intranet-search] OK endpoint=%s query=%r http_status=%s raw_items=%s",
self.endpoint, query, call.get("http_status"), len(items) if isinstance(items, list) else 0,
)
results: List[SearchResult] = []
for i, item in enumerate(items[:max_results]):
if not isinstance(item, dict):
@@ -267,6 +267,69 @@ TEMPLATES: tuple[TemplateSpec, ...] = (
file="no18/no18.html",
convertible=False,
),
TemplateSpec(
id="no19",
name="no.19",
description="",
file="no19/no.19.html",
convertible=False,
),
TemplateSpec(
id="no20",
name="no.20",
description="",
file="no20/no.20.html",
convertible=False,
),
TemplateSpec(
id="no21",
name="no.21",
description="",
file="no21/no.21.html",
convertible=False,
),
TemplateSpec(
id="no22",
name="no.22",
description="",
file="no22/no.22.html",
convertible=False,
),
TemplateSpec(
id="no23",
name="no.23",
description="",
file="no23/no.23.html",
convertible=False,
),
TemplateSpec(
id="no24",
name="no.24",
description="",
file="no24/no.24.html",
convertible=False,
),
TemplateSpec(
id="no25",
name="no.25",
description="",
file="no25/no.25.html",
convertible=False,
),
TemplateSpec(
id="no26",
name="no.26",
description="",
file="no26/no.26.html",
convertible=False,
),
TemplateSpec(
id="no27",
name="no.27",
description="",
file="no27/no.27.html",
convertible=False,
),
)
_TEMPLATES_BY_ID: dict[str, TemplateSpec] = {t.id: t for t in TEMPLATES}
@@ -0,0 +1,381 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>日本涉台介入风险叙事专题研究系统</title>
<style>
:root {
--bg: #8f1f25;
--bg2: #b8323a;
--panel: #fff7f3;
--card: #ffffff;
--ink: #241616;
--muted: #76605d;
--line: rgba(120, 29, 35, .18);
--red: #b7202e;
--red-dark: #7b121b;
--gold: #f3c969;
--soft: #fff2eb;
--green: #18724d;
--orange: #b35b13;
--blue: #275b91;
--shadow: 0 18px 50px rgba(58, 9, 15, .18);
--radius: 18px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
background: radial-gradient(circle at 20% 0%, #d94c4c 0, transparent 24%), linear-gradient(140deg, var(--bg), #641018 70%);
color: var(--ink);
min-height: 100vh;
}
.app { max-width: 1480px; margin: 0 auto; padding: 28px; }
.hero {
color: #fff;
padding: 24px 26px 22px;
border: 1px solid rgba(255,255,255,.18);
background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.06));
backdrop-filter: blur(10px);
border-radius: 26px;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.hero::after {
content: "";
position: absolute;
width: 420px; height: 420px;
border: 1px solid rgba(255,255,255,.12);
border-radius: 50%;
right: -160px; top: -220px;
}
.eyebrow { font-size: 13px; letter-spacing: .18em; opacity: .86; margin-bottom: 9px; }
h1 { margin: 0; font-size: clamp(28px, 4vw, 48px); line-height: 1.15; }
.subtitle { max-width: 920px; margin-top: 12px; color: rgba(255,255,255,.88); line-height: 1.75; }
.hero-meta { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 18px; }
.pill { display:inline-flex; align-items:center; gap:6px; padding: 7px 11px; border-radius: 999px; background: rgba(255,255,255,.16); color: #fff; border: 1px solid rgba(255,255,255,.18); font-size: 13px; }
.toolbar {
margin: 20px 0 14px;
background: var(--panel);
border-radius: 22px;
padding: 14px;
display: grid;
grid-template-columns: 1.6fr .85fr .75fr .75fr;
gap: 12px;
box-shadow: var(--shadow);
border: 1px solid rgba(255,255,255,.42);
}
input, select {
width: 100%;
border: 1px solid var(--line);
background: #fff;
border-radius: 14px;
padding: 12px 13px;
font-size: 14px;
color: var(--ink);
outline: none;
}
input:focus, select:focus { border-color: rgba(183,32,46,.56); box-shadow: 0 0 0 3px rgba(183,32,46,.1); }
.tabs {
display: flex; flex-wrap: wrap; gap: 9px;
margin-bottom: 18px;
}
.tab-btn {
border: 0;
background: rgba(255,255,255,.18);
color: #fff;
border: 1px solid rgba(255,255,255,.2);
padding: 10px 13px;
border-radius: 999px;
cursor: pointer;
font-weight: 700;
transition: .18s ease;
}
.tab-btn.active { background: #fff; color: var(--red-dark); box-shadow: 0 10px 26px rgba(0,0,0,.13); }
.tab-btn:hover { transform: translateY(-1px); }
.panel {
background: var(--panel);
border-radius: 26px;
padding: 20px;
box-shadow: var(--shadow);
border: 1px solid rgba(255,255,255,.44);
min-height: 520px;
}
.tab-head { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; margin-bottom:16px; }
.tab-title { margin: 0; font-size: 25px; color: var(--red-dark); }
.tab-desc { margin: 6px 0 0; color: var(--muted); line-height: 1.6; }
.count-badge { flex: 0 0 auto; padding: 8px 11px; border-radius: 999px; background:#fff; color: var(--red-dark); border:1px solid var(--line); font-weight:800; }
.summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.summary-card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
min-height: 144px;
position: relative;
overflow: hidden;
}
.summary-card::after { content:""; position:absolute; right:-38px; top:-42px; width: 110px; height:110px; border-radius:50%; background: rgba(183,32,46,.08); }
.summary-card h3 { margin:0 0 8px; color:var(--red-dark); font-size:15px; }
.summary-card .value { font-size: 25px; font-weight: 900; margin-bottom: 9px; color: #2b1114; }
.summary-card p { margin:0; color:var(--muted); line-height: 1.62; font-size: 14px; }
.overview-grid { display:grid; grid-template-columns: 1.1fr .9fr; gap: 14px; margin-top: 14px; }
.box { background:#fff; border:1px solid var(--line); border-radius:18px; padding:16px; }
.box h3 { margin:0 0 12px; color: var(--red-dark); }
.bar-row { display:grid; grid-template-columns: 105px 1fr 36px; align-items:center; gap:8px; margin: 9px 0; font-size:13px; color: var(--muted); }
.bar { height: 10px; border-radius: 999px; background: #f4ded8; overflow: hidden; }
.bar > span { display:block; height:100%; border-radius:999px; background: linear-gradient(90deg, var(--red), var(--gold)); }
.risk-list { display:grid; gap:10px; }
.risk-mini { border:1px solid var(--line); border-radius:14px; padding:11px; background:#fffdfb; cursor:pointer; }
.risk-mini strong { color: var(--red-dark); }
.cards { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.card {
background: #fff;
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
display:flex; flex-direction:column; gap: 10px;
min-height: 248px;
transition: .18s ease;
}
.card:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(94, 20, 24, .12); }
.card-top { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
.card-id { font-size:12px; color:#fff; background: var(--red-dark); border-radius:999px; padding:4px 8px; font-weight: 800; }
.risk { font-size:12px; border-radius:999px; padding:5px 8px; font-weight: 800; white-space: nowrap; }
.risk.xh { background: #ffe1de; color:#9b111c; }
.risk.h { background: #fff0d5; color:#9a4e00; }
.risk.m { background: #e9f2ff; color:#24527d; }
.card h3 { margin:0; color:var(--red-dark); font-size:17px; line-height:1.35; }
.meta { display:flex; gap:6px; flex-wrap:wrap; }
.tag { font-size:12px; color:var(--red-dark); background: var(--soft); border:1px solid rgba(183,32,46,.15); border-radius:999px; padding:4px 8px; }
.card p { margin:0; color:var(--muted); line-height: 1.62; font-size: 14px; }
.card-footer { margin-top:auto; display:flex; justify-content:space-between; align-items:center; gap:12px; }
.evidence { font-size:12px; color:#5e4d49; }
.btn {
background: var(--red);
color:#fff;
border:0;
border-radius:12px;
padding:9px 12px;
cursor:pointer;
font-weight:800;
box-shadow: 0 8px 18px rgba(183,32,46,.22);
}
.btn.secondary { background:#fff; color:var(--red-dark); border:1px solid var(--line); box-shadow:none; }
.empty { padding: 38px; text-align:center; color:var(--muted); background:#fff; border:1px dashed var(--line); border-radius:18px; }
.keyword-wrap { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:14px; margin-top:16px; }
.keyword-group { background:#fff; border:1px solid var(--line); border-radius:18px; padding:15px; }
.keyword-group h3 { margin:0 0 10px; color: var(--red-dark); }
.kw { display:inline-block; margin:5px; padding:7px 10px; background:var(--soft); border:1px solid rgba(183,32,46,.18); color:var(--red-dark); border-radius:999px; font-size:13px; }
.modal-backdrop { display:none; position:fixed; inset:0; background:rgba(45, 7, 10, .62); z-index:1000; padding: 28px; overflow:auto; }
.modal-backdrop.open { display:block; }
.modal { max-width: 980px; margin: 0 auto; background:#fffaf7; border-radius: 24px; border:1px solid rgba(255,255,255,.45); box-shadow: 0 30px 90px rgba(0,0,0,.34); overflow:hidden; }
.modal-head { background: linear-gradient(135deg, var(--red-dark), var(--red)); color:#fff; padding: 20px 22px; display:flex; justify-content:space-between; gap:16px; align-items:flex-start; }
.modal-head h2 { margin:0; font-size:24px; }
.close { background:rgba(255,255,255,.16); border:1px solid rgba(255,255,255,.25); color:#fff; border-radius:12px; font-size:22px; cursor:pointer; width:42px; height:38px; }
.modal-body { padding: 20px 22px 24px; }
.detail-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap:10px; margin-bottom:16px; }
.detail-cell { background:#fff; border:1px solid var(--line); border-radius:14px; padding:10px; }
.detail-cell span { display:block; font-size:12px; color:var(--muted); margin-bottom:4px; }
.detail-cell strong { color:var(--red-dark); }
.section { background:#fff; border:1px solid var(--line); border-radius:16px; padding:15px; margin:12px 0; }
.section h3 { margin:0 0 8px; color:var(--red-dark); }
.section p { margin:0; line-height:1.8; color:#3b2929; }
.footer-note { color:rgba(255,255,255,.78); text-align:center; padding: 16px 0 4px; font-size:13px; }
@media (max-width: 1100px) {
.toolbar { grid-template-columns: 1fr 1fr; }
.summary-grid, .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.overview-grid { grid-template-columns: 1fr; }
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.app { padding: 14px; }
.toolbar { grid-template-columns: 1fr; }
.summary-grid, .cards, .keyword-wrap { grid-template-columns: 1fr; }
.tab-head { flex-direction:column; }
.detail-grid { grid-template-columns: 1fr; }
.modal-backdrop { padding: 12px; }
}
</style>
</head>
<body>
<div class="app">
<header class="hero">
<div class="eyebrow">NARRATIVE RISK MONITORING DATABASE</div>
<h1>日本涉台介入风险叙事专题研究系统</h1>
<div class="subtitle">围绕“介入战争日本祸及自身”叙事的事件、人物、组织、制度、地方、媒体、经济与民意数据库</div>
<div class="hero-meta">
<span class="pill">版本:1.0</span>
<span class="pill">更新:2026-06-30</span>
<span class="pill">离线可用 · 内嵌数据</span>
<span class="pill">Tab切换 · 搜索筛选 · 弹框详情</span>
</div>
</header>
<section class="toolbar">
<input id="q" placeholder="搜索关键词:高市、先岛、存立危机、冲绳、旅游、认知战……" />
<select id="narrativeFilter"><option value="">全部叙事方向</option></select>
<select id="riskFilter"><option value="">全部风险等级</option><option value="极高">极高</option><option value="高">高</option><option value="中">中</option><option value="中低">中低</option><option value="低">低</option></select>
<select id="evidenceFilter"><option value="">全部证据状态</option><option value="已核实">已核实</option><option value="持续更新">持续更新</option><option value="待采集">待采集</option></select>
</section>
<nav class="tabs" id="tabs"></nav>
<main class="panel" id="content"></main>
<div class="footer-note">本页面为研究型结构化展示,不替代原始文件核验;高风险条目应回溯国会记录、政府文件、机构公告和媒体原文。</div>
</div>
<div class="modal-backdrop" id="modalBackdrop" onclick="backdropClose(event)">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-head">
<div>
<div id="modalMeta" class="eyebrow"></div>
<h2 id="modalTitle"></h2>
</div>
<button class="close" onclick="closeModal()">×</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<script>
const DATA = {"project": {"title": "日本涉台介入风险叙事专题研究系统", "subtitle": "围绕“介入战争日本祸及自身”叙事的事件、人物、组织、制度、地方、媒体、经济与民意数据库", "version": "1.0", "language": "zh-CN", "last_updated": "2026-06-30", "description": "本数据用于展示日本方向涉台介入风险叙事中的事件、人物、组织、制度、地方、媒体、经济、民意与竞争叙事。"}, "tabs": [{"id": "overview", "name": "总览仪表盘", "dataset": null, "description": "展示专题总风险、叙事分布、热点地区、重点人物和最新事件。"}, {"id": "timeline", "name": "事件时间线", "dataset": "events_timeline", "description": "展示2014年以来日本涉台安全政策、政要表态、地方避难和中日冲突节点。"}, {"id": "actors_orgs", "name": "人物组织", "dataset": ["actors_japan", "organizations"], "description": "展示日本政府、政党、地方政治、专家、媒体和社会组织。"}, {"id": "security_laws", "name": "法律制度", "dataset": "security_laws", "description": "展示存立危机、重要影响、国民保护、日美安保等制度路径。"}, {"id": "local_frontline", "name": "地方前线", "dataset": "local_frontline", "description": "展示冲绳、先岛、石垣、宫古、与那国、九州、山口等地方风险。"}, {"id": "antiwar_networks", "name": "反战护宪网络", "dataset": "antiwar_networks", "description": "展示九条之会、日弁连、冲绳和平运动、地方反导弹组织和工会网络。"}, {"id": "conservative_backlash", "name": "保守反弹", "dataset": "conservative_backlash", "description": "展示威慑、防卫、中国认知战、台湾伙伴等竞争叙事。"}, {"id": "media_platform", "name": "媒体平台", "dataset": "media_platform_samples", "description": "展示全国媒体、地方媒体、YouTube、X、Yahoo、note等传播样本。"}, {"id": "economic_spillover", "name": "经济外溢", "dataset": "economic_spillover", "description": "展示旅游、航空、半导体、供应链、出口管制等风险。"}, {"id": "public_opinion", "name": "民意受众", "dataset": "public_opinion_youth", "description": "展示台湾有事、防卫费、修宪、青年、都市无党派和冲绳地方态度。"}, {"id": "japan_taiwan", "name": "日台关系", "dataset": "japan_taiwan_relations", "description": "展示日台实务窗口、亲台议员网络、地方交流、半导体合作和智库推演。"}, {"id": "corpus_keywords", "name": "语料词库", "dataset": "source_corpus_keywords", "description": "展示原始语料来源、日文关键词、平台监测词和证据状态。"}], "dashboard": {"summary_cards": [{"title": "核心叙事", "value": "介入战争日本祸及自身", "description": "围绕日本涉台介入可能引发战争卷入、地方牺牲、经济反制和社会撕裂的风险叙事。"}, {"title": "数据条目", "value": "164", "description": "覆盖事件、人物、组织、制度、地方、媒体、经济、民意和日台关系。"}, {"title": "高风险地区", "value": "冲绳、先岛、九州", "description": "冲绳本岛、石垣、宫古、与那国、九州和山口是地方风险最集中的区域。"}, {"title": "核心法律入口", "value": "存立危机事态", "description": "台湾有事是否触发日本集体自卫权,是舆论和国会争议核心。"}, {"title": "主要竞争叙事", "value": "威慑才能防止战争", "description": "保守派和安保界常用威慑、防卫、中国威胁等逻辑反击反介入叙事。"}, {"title": "总体风险", "value": "86 / 100", "description": "综合政治触发、地方影响、经济外溢、媒体扩散和反弹强度评估。"}], "narrative_distribution": [{"label": "地方牺牲", "count": 38}, {"label": "护宪反战", "count": 22}, {"label": "威慑防战", "count": 20}, {"label": "经济代价", "count": 18}, {"label": "中性解释", "count": 17}, {"label": "台湾伙伴", "count": 15}, {"label": "战争卷入", "count": 12}, {"label": "专业治理", "count": 12}, {"label": "中国胁迫", "count": 6}, {"label": "中国威胁", "count": 4}], "hotspots": [{"location": "日本", "count": 42}, {"location": "东京", "count": 15}, {"location": "冲绳", "count": 12}, {"location": "日本全国", "count": 9}, {"location": "宫古岛", "count": 5}, {"location": "华盛顿", "count": 4}, {"location": "与那国", "count": 4}, {"location": "石垣", "count": 3}, {"location": "九州/山口", "count": 3}, {"location": "熊本", "count": 3}, {"location": "先岛/九州/山口", "count": 2}, {"location": "中日之间", "count": 2}], "top_risks": [{"id": "E016", "title_cn": "高市早苗称台湾有事可能构成存立危机事态", "category": "首相表态", "summary": "高市早苗在国会答辩中将台湾有事与存立危机事态直接相连。", "detail": "该事件将保守派长期话语推入首相级官Line truncated
const state = { activeTab: 'overview' };
const tabsEl = document.getElementById('tabs');
const contentEl = document.getElementById('content');
const qEl = document.getElementById('q');
const narrativeEl = document.getElementById('narrativeFilter');
const riskEl = document.getElementById('riskFilter');
const evidenceEl = document.getElementById('evidenceFilter');
function init() {
Object.values(DATA.narrative_labels).forEach(label => {
const opt = document.createElement('option'); opt.value = label; opt.textContent = label; narrativeEl.appendChild(opt);
});
renderTabs();
render();
[qEl, narrativeEl, riskEl, evidenceEl].forEach(el => el.addEventListener('input', render));
}
function renderTabs() {
tabsEl.innerHTML = '';
DATA.tabs.forEach(t => {
const btn = document.createElement('button');
btn.className = 'tab-btn' + (state.activeTab === t.id ? ' active' : '');
btn.textContent = t.name;
btn.onclick = () => { state.activeTab = t.id; qEl.value=''; renderTabs(); render(); };
tabsEl.appendChild(btn);
});
}
function getActiveTab() { return DATA.tabs.find(t => t.id === state.activeTab); }
function getItems(tab) {
if (!tab || !tab.dataset) return [];
if (Array.isArray(tab.dataset)) return tab.dataset.flatMap(k => DATA.datasets[k] || []);
return DATA.datasets[tab.dataset] || [];
}
function textOf(x) {
return [x.id, x.title_cn, x.category, x.summary, x.detail, x.location, x.narrative_label, ...(x.tags||[])].join(' ').toLowerCase();
}
function filtered(items) {
const q = qEl.value.trim().toLowerCase();
const n = narrativeEl.value;
const r = riskEl.value;
const e = evidenceEl.value;
return items.filter(x => (!q || textOf(x).includes(q)) && (!n || x.narrative_label === n) && (!r || x.risk_level === r) && (!e || x.evidence_status === e));
}
function riskClass(level) { return level === '极高' ? 'xh' : level === '高' ? 'h' : 'm'; }
function render() {
const tab = getActiveTab();
if (state.activeTab === 'overview') return renderOverview();
const items = filtered(getItems(tab));
const keywordBlock = state.activeTab === 'corpus_keywords' ? renderKeywords() : '';
contentEl.innerHTML = `
<div class="tab-head">
<div><h2 class="tab-title">${tab.name}</h2><p class="tab-desc">${tab.description}</p></div>
<div class="count-badge">${items.length} 条</div>
</div>
${items.length ? `<div class="cards">${items.map(card).join('')}</div>` : '<div class="empty">没有符合当前筛选条件的数据。</div>'}
${keywordBlock}
`;
}
function renderOverview() {
const d = DATA.dashboard;
const maxNarr = Math.max(...d.narrative_distribution.map(x => x.count));
const maxHot = Math.max(...d.hotspots.map(x => x.count));
contentEl.innerHTML = `
<div class="tab-head">
<div><h2 class="tab-title">总览仪表盘</h2><p class="tab-desc">展示专题总风险、叙事分布、热点地区、重点人物和最新事件。可通过上方搜索框对全库进行粗略检索。</p></div>
<div class="count-badge">${Object.values(DATA.datasets).filter(Array.isArray).flat().length - DATA.datasets.keywords.length} 条</div>
</div>
<div class="summary-grid">${d.summary_cards.map(c => `<div class="summary-card"><h3>${c.title}</h3><div class="value">${c.value}</div><p>${c.description}</p></div>`).join('')}</div>
<div class="overview-grid">
<div class="box"><h3>叙事分布</h3>${d.narrative_distribution.map(x => `<div class="bar-row"><span>${x.label}</span><div class="bar"><span style="width:${Math.max(8, x.count/maxNarr*100)}%"></span></div><b>${x.count}</b></div>`).join('')}</div>
<div class="box"><h3>热点地区</h3>${d.hotspots.slice(0,10).map(x => `<div class="bar-row"><span>${x.location}</span><div class="bar"><span style="width:${Math.max(8, x.count/maxHot*100)}%"></span></div><b>${x.count}</b></div>`).join('')}</div>
</div>
<div class="overview-grid">
<div class="box"><h3>高风险条目</h3><div class="risk-list">${d.top_risks.slice(0,6).map(x => `<div class="risk-mini" onclick="openModalById('${x.id}')"><strong>${x.title_cn}</strong><br><span>${x.category} · 风险 ${x.risk_score} · ${x.narrative_label}</span></div>`).join('')}</div></div>
<div class="box"><h3>主要风险驱动</h3><div class="meta">${d.risk_drivers.map(x => `<span class="tag">${x}</span>`).join('')}</div><div class="section"><h3>页面使用建议</h3><p>先从“事件时间线”定位触发节点,再到“法律制度”查看制度路径;涉及地方影响时进入“地方前线”和“反战护宪网络”;涉及传播扩散时进入“媒体平台”;涉及成本量化时进入“经济外溢”和“民意受众”。</p></div></div>
</div>
`;
}
function card(x) {
return `<article class="card">
<div class="card-top"><span class="card-id">${x.id}</span><span class="risk ${riskClass(x.risk_level)}">${x.risk_level} · ${x.risk_score}</span></div>
<h3>${x.title_cn}</h3>
<div class="meta"><span class="tag">${x.category}</span><span class="tag">${x.narrative_label}</span><span class="tag">${x.location || '—'}</span></div>
<p>${x.summary}</p>
<div class="meta">${(x.tags || []).slice(0,4).map(t => `<span class="tag">${t}</span>`).join('')}</div>
<div class="card-footer"><span class="evidence">证据:${x.evidence_status} · 可信度${x.confidence_level}</span><button class="btn" onclick="openModalById('${x.id}')">查看详情</button></div>
</article>`;
}
function renderKeywords() {
return `<div class="keyword-wrap">${DATA.datasets.keywords.map(g => `<div class="keyword-group"><h3>${g.group}</h3>${g.terms.map(t => `<span class="kw">${t}</span>`).join('')}</div>`).join('')}</div>`;
}
function allItems() { return Object.entries(DATA.datasets).filter(([k]) => k !== 'keywords').flatMap(([k,v]) => Array.isArray(v) ? v : []); }
function openModalById(id) { const x = allItems().find(i => i.id === id); if (x) openModal(x); }
function openModal(x) {
document.getElementById('modalMeta').textContent = `${x.id} · ${x.category} · ${x.narrative_label}`;
document.getElementById('modalTitle').textContent = x.title_cn;
const metrics = x.metrics ? `<div class="section"><h3>关键指标</h3><p>${Object.entries(x.metrics).map(([k,v]) => `${k}:${v}`).join(';')}</p></div>` : '';
document.getElementById('modalBody').innerHTML = `
<div class="detail-grid">
<div class="detail-cell"><span>风险等级</span><strong>${x.risk_level} · ${x.risk_score}</strong></div>
<div class="detail-cell"><span>叙事方向</span><strong>${x.narrative_label}</strong></div>
<div class="detail-cell"><span>时间</span><strong>${x.date || '持续'}</strong></div>
<div class="detail-cell"><span>地点</span><strong>${x.location || '—'}</strong></div>
<div class="detail-cell"><span>证据状态</span><strong>${x.evidence_status}</strong></div>
<div class="detail-cell"><span>可信度</span><strong>${x.confidence_level}</strong></div>
<div class="detail-cell"><span>类别</span><strong>${x.category}</strong></div>
<div class="detail-cell"><span>编号</span><strong>${x.id}</strong></div>
</div>
<div class="section"><h3>摘要</h3><p>${x.summary}</p></div>
<div class="section"><h3>详情解读</h3><p>${x.detail}</p></div>
${metrics}
<div class="section"><h3>关联标签</h3><p>${(x.tags || []).map(t => `#${t}`).join(' ')}</p></div>
<div class="section"><h3>展示建议</h3><p>该条目可与同类事件、相关人物组织、地方节点、法律制度和媒体平台样本联动。若用于二级页面,建议补充时间线、争议双方观点、原始语料和平台传播样本。</p></div>
`;
document.getElementById('modalBackdrop').classList.add('open');
}
function closeModal() { document.getElementById('modalBackdrop').classList.remove('open'); }
function backdropClose(e) { if (e.target.id === 'modalBackdrop') closeModal(); }
window.addEventListener('keydown', e => { if(e.key === 'Escape') closeModal(); });
init();
</script>
</body>
</html>
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>菲律宾“不做美国棋子”叙事专题数据库 v0.2</title>
<style>
:root{--bg:#f7f1ed;--card:#fffdfb;--ink:#291817;--muted:#7a6764;--line:#eadbd4;--red:#a23434;--red2:#6f1f1f;--blue:#244b74;--blue2:#eaf2fb;--gold:#9f6a1a;--green:#477052;--shadow:0 18px 50px rgba(51,20,14,.09)}
*{box-sizing:border-box} body{margin:0;background:linear-gradient(180deg,#f6eee9 0,#fbf7f3 100%);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",Arial,sans-serif;line-height:1.55}
header{padding:26px 30px 18px;background:linear-gradient(135deg,#8f2929,#b6473d 62%,#d8a16f);color:white;position:sticky;top:0;z-index:20;box-shadow:0 12px 30px rgba(76,17,17,.18)}
.topline{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;max-width:1500px;margin:0 auto}.title h1{font-size:28px;margin:0 0 6px}.title p{margin:0;opacity:.88}.tools{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.search{min-width:280px;border:0;border-radius:14px;padding:12px 14px;font-size:14px;outline:none}.btn,label.btn{border:1px solid rgba(255,255,255,.55);background:rgba(255,255,255,.14);color:white;border-radius:13px;padding:10px 13px;cursor:pointer;font-size:14px}label.btn input{display:none}
nav{background:#fff8f5;border-bottom:1px solid var(--line);position:sticky;top:105px;z-index:19}.tabs{display:flex;gap:8px;overflow:auto;padding:12px 28px;max-width:1500px;margin:0 auto}.tab{border:1px solid var(--line);background:#fff;border-radius:999px;padding:10px 15px;white-space:nowrap;cursor:pointer;color:var(--ink)}.tab.active{background:var(--red);color:white;border-color:var(--red)}
.wrap{display:grid;grid-template-columns:280px minmax(0,1fr) 260px;gap:18px;max-width:1500px;margin:18px auto;padding:0 24px 40px}.panel,.card,.modal{background:var(--card);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:22px}.filters{padding:16px;position:sticky;top:170px;align-self:start}.filters h3,.side h3{margin:0 0 12px}.field{margin:0 0 12px}.field label{display:block;color:var(--muted);font-size:12px;margin:0 0 5px}.field input,.field select{width:100%;border:1px solid var(--line);background:#fff;border-radius:12px;padding:10px 11px;outline:none}.field input:focus,.field select:focus{border-color:#ce9287}.reset{width:100%;border:1px solid var(--line);background:#fff;border-radius:12px;padding:10px;cursor:pointer}.side{padding:16px;position:sticky;top:170px;align-self:start}.summary-item{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius:14px;padding:10px 12px;margin:8px 0;background:#fff}.summary-item strong{font-size:19px;color:var(--red)}.summary-item span{font-size:13px;color:var(--muted)}
main{min-width:0}.section-title{display:flex;align-items:center;justify-content:space-between;gap:14px;margin:0 0 14px}.section-title h2{margin:0;font-size:24px}.section-title .sub{color:var(--muted);font-size:13px}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:16px}.stat{padding:18px;border-radius:20px;background:#fff;border:1px solid var(--line);box-shadow:var(--shadow)}.stat .num{font-size:32px;font-weight:800;color:var(--red)}.stat .lab{color:var(--muted)}.two-col{display:grid;grid-template-columns:1.15fr .85fr;gap:14px}.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{padding:16px;cursor:pointer;transition:.18s;min-height:176px}.card:hover{transform:translateY(-2px);box-shadow:0 22px 60px rgba(51,20,14,.15);border-color:#d7b9ac}.card h3{margin:8px 0 8px;font-size:18px}.card p{margin:0 0 10px;color:#5b4744}.meta-row{display:flex;gap:7px;align-items:center;flex-wrap:wrap}.pill{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--line);border-radius:999px;padding:4px 8px;font-size:12px;background:#fff;color:#5d4c49;margin:2px}.pill.red{background:#fff0ee;color:var(--red2);border-color:#ebc6bf}.pill.blue{background:var(--blue2);color:var(--blue);border-color:#cfe0ee}.pill.green{background:#eef8ef;color:var(--green);border-color:#cfe6d2}.pill.gold{background:#fff6e6;color:var(--gold);border-color:#ecd7ad}.risk-high{background:#fff0ee;color:#8b1d1d;border-color:#e8beb8}.risk-medium{background:#fff7e8;color:#8a5b08;border-color:#ead6a4}.risk-low{background:#eef9f1;color:#426f49;border-color:#cfe7d5}.small{font-size:12px;color:var(--muted)}.badge-title{font-size:12px;color:var(--muted);margin-top:12px;text-transform:uppercase;letter-spacing:.06em}.timeline{position:relative;padding-left:18px}.timeline:before{content:"";position:absolute;left:4px;top:6px;bottom:6px;width:2px;background:#e3c8c0}.titem{position:relative;margin:0 0 14px}.titem:before{content:"";position:absolute;left:-21px;top:20px;width:12px;height:12px;border-radius:50%;background:var(--red);box-shadow:0 0 0 5px #f8e8e4}.tcard{background:#fff;border:1px solid var(--line);border-radius:18px;padding:15px;cursor:pointer}.tdate{font-size:12px;color:var(--red);font-weight:700}.table-wrap{overflow:auto;background:#fff;border:1px solid var(--line);border-radius:18px}table{border-collapse:separate;border-spacing:0;width:100%;min-width:900px}th,td{border-bottom:1px solid var(--line);padding:12px 12px;text-align:left;vertical-align:top}th{background:#fff5f2;color:#6a3330;font-size:13px;position:sticky;top:0}td{font-size:14px}.viewbar{display:flex;gap:8px;justify-content:flex-end;margin:-4px 0 12px}.viewbar button, .inline-btn{border:1px solid var(--line);background:#fff;border-radius:999px;padding:8px 12px;cursor:pointer}.viewbar button.active{background:var(--red);color:#fff;border-color:var(--red)}.matrix{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.risk-col{border:1px solid var(--line);background:#fff;border-radius:20px;padding:14px}.empty{border:1px dashed #d7bdb5;border-radius:18px;padding:35px;text-align:center;color:var(--muted);background:#fff}.linkish,.rel-card{cursor:pointer}.rel-card{border:1px solid var(--line);border-radius:14px;padding:10px;background:#fff;margin:6px 0}.rel-card strong{display:block}.rel-card span{color:var(--muted);font-size:12px}.mini-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.distance-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.distance{background:#fff;border:1px solid var(--line);border-radius:16px;padding:12px}.distance strong{display:block;color:var(--red);font-size:22px}.distance span{font-size:12px;color:var(--muted)}
.modal-backdrop{position:fixed;inset:0;background:rgba(38,15,12,.5);display:none;align-items:center;justify-content:center;z-index:100;padding:20px}.modal-backdrop.show{display:flex}.modal{width:min(1120px,100%);max-height:90vh;overflow:auto}.modal-head{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:12px;align-items:flex-start;position:sticky;top:0;background:#fffdfb;z-index:2}.modal-title{margin:0;font-size:24px}.close{border:0;background:#f4e6e2;border-radius:12px;width:40px;height:40px;font-size:24px;cursor:pointer}.modal-body{padding:20px 24px 28px}.hero{padding:18px;border:1px solid var(--line);border-radius:20px;background:linear-gradient(135deg,#fff7f4,#fffdfb);margin-bottom:14px}.hero h3{margin:0 0 8px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.detail-section{border:1px solid var(--line);background:#fff;border-radius:18px;padding:15px;margin-bottom:14px}.detail-section h4{margin:0 0 10px;color:var(--red2);font-size:16px}.detail-section p{margin:6px 0}.detail-section ul{margin:8px 0 0;padding-left:20px}.obj-table{width:100%;min-width:0}.obj-table th,.obj-table td{font-size:13px;padding:8px}.full{grid-column:1/-1}.note-box{background:#fff9ef;border:1px solid #ecd5ad;border-radius:16px;padding:12px}.source-list{font-size:12px;color:var(--muted);line-height:1.6}.kpi-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:12px 0}.kpi{border:1px solid var(--line);border-radius:16px;padding:12px;background:#fff}.kpi b{display:block;color:var(--red);font-size:20px}.kpi span{font-size:12px;color:var(--muted)}
@media(max-width:1120px){.wrap{grid-template-columns:1fr}.filters,.side{position:relative;top:0}.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.two-col,.matrix,.detail-grid{grid-template-columns:1fr}nav{top:120px}.distance-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:680px){header{position:relative}.topline{display:block}.tools{margin-top:12px}.search{min-width:100%;width:100%}nav{position:relative;top:0}.wrap{padding:0 12px}.cards{grid-template-columns:1fr}.stats{grid-template-columns:1fr}.distance-grid,.mini-grid,.kpi-strip{grid-template-columns:1fr}.modal-backdrop{padding:6px}.modal-head{padding:16px}.modal-body{padding:14px}.tabs{padding:10px 12px}}
@media print{header,nav,.filters,.side,.tools,.modal-backdrop{display:none!important}.wrap{display:block;max-width:none}.card,.panel,.stat{box-shadow:none}.cards{grid-template-columns:1fr 1fr}}
</style>
</head>
<body>
<header><div class="topline"><div class="title"><h1 id="projectTitle">专题数据库</h1><p id="projectSubtitle"></p></div><div class="tools"><input id="globalSearch" class="search" placeholder="全局搜索:EDCA / Cagayan / BAYAN / 台海 / WPS"><label class="btn">导入JSON<input id="jsonFile" type="file" accept="application/json,.json"></label><button class="btn" onclick="downloadJson()">导出JSON</button></div></div></header>
<nav><div class="tabs" id="tabs"></div></nav>
<div class="wrap"><aside class="panel filters"><h3>筛选器</h3><div class="field"><label>关键词</label><input id="filterKeyword" placeholder="在当前库内搜索"></div><div class="field"><label>年份</label><select id="filterYear"></select></div><div class="field"><label>事件类型</label><select id="filterCategory"></select></div><div class="field"><label>省份</label><select id="filterProvince"></select></div><div class="field"><label>阵营</label><select id="filterCamp"></select></div><div class="field"><label>风险等级</label><select id="filterRisk"></select></div><div class="field"><label>战略方向</label><select id="filterAxis"></select></div><button class="reset" onclick="resetFilters()">清空筛选</button><div class="source-list" style="margin-top:14px">说明:本页面为公开资料研究展示工具,复杂字段以详情弹框结构化展示,不再输出原始JSON。</div></aside><main id="main"></main><aside class="panel side"><h3>数据摘要</h3><div id="summaryPanel"></div><h3 style="margin-top:18px">高频标签</h3><div id="tagCloud"></div></aside></div>
<div class="modal-backdrop" id="modalBackdrop" onclick="backdropClose(event)"><div class="modal"><div class="modal-head"><div><h2 id="modalTitle" class="modal-title"></h2><div id="modalMeta" class="meta-row"></div></div><button class="close" onclick="closeModal()">×</button></div><div id="modalBody" class="modal-body"></div></div></div>
<script id="db-json" type="application/json">{"meta": {"project_title": "菲律宾“不做美国棋子”叙事专题数据库", "project_subtitle": "Philippines “Not a US Pawn” Narrative Research Dashboard", "version": "0.2", "last_updated": "2026-06-29", "language": "zh-CN", "description": "用于展示菲律宾“菲律宾不做美国棋子”叙事专题的结构化知识库,覆盖EDCA地点、事件线、人物组织、叙事结构、竞争叙事、风险核查和跨库关联查询。", "scope_note": "本数据库服务于公开资料研究、舆情观察、风险评估与专题分析,不用于隐蔽影响、虚假传播或操控性动员。", "revision_note": "v0.2增强版:弹框详情改为结构化页面展示,补充研究字段,尽量保留前期MD知识库中的分析内容。"}, "tabs": [{"id": "overview", "label": "专题总览", "type": "dashboard"}, {"id": "timeline", "label": "事件时间线", "type": "timeline"}, {"id": "edca_sites", "label": "EDCA地点库", "type": "cards_table"}, {"id": "actors", "label": "人物组织库", "type": "cards_table"}, {"id": "narratives", "label": "叙事结构库", "type": "cards"}, {"id": "counter_narratives", "label": "竞争叙事库", "type": "cards"}, {"id": "risks", "label": "风险核查库", "type": "risk_matrix"}, {"id": "search", "label": "综合查询", "type": "search"}], "dictionaries": {"event_categories": {"edca_signature": "EDCA签署/制度化", "edca_expansion": "EDCA扩展", "edca_infrastructure": "EDCA基建", "balikatan": "Balikatan军演", "china_coast_guard": "中国海警事件", "ph_official_protest": "菲律宾官方抗议", "us_ph_meeting": "美菲高层会晤", "anti_edca_protest": "反EDCA/反军演抗议", "multilateral_security": "多边安全合作", "wps_civil_action": "WPS民间行动"}, "strategic_axis": {"taiwan_bashi": "台海—巴士海峡方向", "wps_scs": "西菲律宾海—南海方向", "northern_luzon": "北吕宋前沿方向", "central_luzon": "中吕宋支撑方向", "central_logistics": "中部群岛后勤方向", "mindanao": "棉兰老方向", "hadr": "人道援助与灾害响应"}, "actor_camps": {"marcos_official": "马科斯官方阵营", "duterte_camp": "杜特尔特阵营", "left_anti_base": "左翼反基地阵营", "wps_sovereignty": "WPS主权派", "fact_check": "事实核查机构", "security_expert": "安全专家与智库", "external_actor": "外部行为体"}, "actor_types": {"official_person": "官方人物", "political_person": "政治人物", "organization": "组织", "party_bloc": "政党/党团", "institution": "机构", "expert": "专家", "think_tank": "智库", "media_factcheck": "事实核查/媒体"}, "stance_values": {"support": "支持", "oppose": "反对", "mixed": "复杂/混合", "neutral": "中立", "unknown": "未知"}, "risk_levels": {"low": "低", "medium": "中", "high": "高"}, "site_types": {"naval_base": "海军基地", "airport": "机场", "army_camp": "陆军营地", "air_base": "空军基地", "airfield": "机场/航空场"}, "field_labels": {"id": "编号", "name": "名称", "name_cn": "中文名", "name_en": "英文名", "title": "标题", "summary": "摘要", "brief": "简述", "description": "说明", "province": "省份", "city_or_area": "城市/区域", "designation_year": "纳入年份", "site_type": "地点类型", "strategic_axis": "战略方向", "risk_level": "风险等级", "local_position": "地方态度", "tags": "标签", "distances": "近似距离", "detail": "详情", "strategic_value": "战略意义", "local_political_positions": "地方政治人物表态", "local_reactions": "地方居民抗议或支持", "infrastructure_projects": "基建项目", "risk_controversies": "风险争议", "official_response": "官方回应", "related_events": "关联事件", "related_actors": "关联人物组织", "related_sites": "关联地点", "background": "背景", "opposition_position": "反对方立场", "ph_official_position": "菲律宾官方立场", "us_position": "美国立场", "china_position": "中国立场", "research_note": "研究备注", "category": "类别", "date": "日期", "year": "年份", "location": "地点", "actors": "相关行为体", "narrative_signal": "叙事信号", "counter_narrative": "竞争叙事/反驳框架", "current_role": "当前身份", "camp": "阵营", "type": "类型", "stance_on_edca": "对EDCA立场", "stance_on_wps": "对WPS立场", "influence_level": "影响等级", "narrative_function": "叙事功能", "typical_positions": "典型立场", "typical_keywords": "典型关键词", "risk_note": "风险备注", "profile": "画像", "monitoring_focus": "监测重点", "core_claims": "核心命题", "target_audiences": "目标受众", "typical_expressions": "典型表达", "strength": "叙事强项", "weakness": "叙事弱项", "counter_narratives": "竞争叙事", "main_speakers": "主要提出者", "used_against": "用于反驳", "evidence_types": "证据类型", "advantage": "优势", "limitation": "限制", "trigLine truncated
<script>
let DB = JSON.parse(document.getElementById('db-json').textContent);
const $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
let state={tab:'overview',view:'cards',filters:{},global:''};
const collMap={event:'events',edca_site:'edca_sites',actor:'actors',narrative:'narratives',counter_narrative:'counter_narratives',risk:'risks'};
const tabsForType={event:'timeline',edca_site:'edca_sites',actor:'actors',narrative:'narratives',counter_narrative:'counter_narratives',risk:'risks'};
function arr(x){return Array.isArray(x)?x:[]}
function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
function dict(group,key){return DB.dictionaries?.[group]?.[key] || key || ''}
function label(k){return DB.dictionaries?.field_labels?.[k] || k}
function titleOf(o){return o?.title || o?.name_cn || o?.name || o?.name_en || o?.id || ''}
function summaryOf(o){return o?.summary || o?.brief || o?.description || o?.narrative_function || ''}
function risk(level){return level?`<span class="pill risk-${esc(level)}">风险:${esc(dict('risk_levels',level))}</span>`:''}
function chips(tags,cls=''){return arr(tags).map(t=>`<span class="pill ${cls}">${esc(t)}</span>`).join('')}
function getAll(){let out=[];Object.entries(collMap).forEach(([type,key])=>arr(DB.data?.[key]).forEach(x=>out.push({...x,__type:type})));return out}
function find(id){for(const [type,key] of Object.entries(collMap)){const x=arr(DB.data?.[key]).find(i=>i.id===id); if(x) return {...x,__type:type}}return null}
function searchText(o){return JSON.stringify(o).toLowerCase()}
function matchKw(o,kw){return !kw || searchText(o).includes(kw.toLowerCase())}
function setup(){ $('#projectTitle').textContent=DB.meta.project_title; $('#projectSubtitle').textContent=(DB.meta.project_subtitle||'')+' · '+(DB.meta.revision_note||''); renderTabs(); fillFilters(); bind(); render(); }
function renderTabs(){ $('#tabs').innerHTML=arr(DB.tabs).map(t=>`<button class="tab ${state.tab===t.id?'active':''}" data-id="${esc(t.id)}">${esc(t.label)}</button>`).join(''); $$('#tabs .tab').forEach(b=>b.onclick=()=>{state.tab=b.dataset.id;renderTabs();render();}); }
function optAll(label){return `<option value="">${label}</option>`}
function fillFilters(){ const years=[...new Set(arr(DB.data.events).map(e=>e.year).filter(Boolean))].sort((a,b)=>a-b); $('#filterYear').innerHTML=optAll('全部年份')+years.map(y=>`<option>${y}</option>`).join(''); $('#filterCategory').innerHTML=optAll('全部类型')+Object.entries(DB.dictionaries.event_categories||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); const ps=[...new Set(arr(DB.data.edca_sites).map(s=>s.province).filter(Boolean))].sort(); $('#filterProvince').innerHTML=optAll('全部省份')+ps.map(p=>`<option>${esc(p)}</option>`).join(''); $('#filterCamp').innerHTML=optAll('全部阵营')+Object.entries(DB.dictionaries.actor_camps||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); $('#filterRisk').innerHTML=optAll('全部风险')+Object.entries(DB.dictionaries.risk_levels||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); $('#filterAxis').innerHTML=optAll('全部方向')+Object.entries(DB.dictionaries.strategic_axis||{}).map(([k,v])=>`<option value="${esc(k)}">${esc(v)}</option>`).join(''); }
function bind(){ ['filterKeyword','filterYear','filterCategory','filterProvince','filterCamp','filterRisk','filterAxis'].forEach(id=>{$('#'+id).oninput=collect;$('#'+id).onchange=collect}); $('#globalSearch').oninput=e=>{state.global=e.target.value.trim(); if(state.global){state.tab='search';renderTabs()} render()}; $('#jsonFile').onchange=importJson; window.onkeydown=e=>{if(e.key==='Escape')closeModal()}; }
function collect(){ state.filters={keyword:$('#filterKeyword').value.trim(),year:$('#filterYear').value,category:$('#filterCategory').value,province:$('#filterProvince').value,camp:$('#filterCamp').value,risk:$('#filterRisk').value,axis:$('#filterAxis').value}; render(); }
function resetFilters(){['filterKeyword','filterYear','filterCategory','filterProvince','filterCamp','filterRisk','filterAxis','globalSearch'].forEach(id=>$('#'+id).value='');state.filters={};state.global='';render()}
function commonFilter(items){const f=state.filters||{};return items.filter(x=>{if(f.keyword&&!matchKw(x,f.keyword))return false;if(f.risk&&x.risk_level!==f.risk)return false;if(f.year&&x.year&&String(x.year)!==String(f.year))return false;if(f.category&&x.category&&x.category!==f.category)return false;if(f.province&&x.province&&x.province!==f.province)return false;if(f.camp&&x.camp&&x.camp!==f.camp)return false;if(f.axis&&x.strategic_axis&&!arr(x.strategic_axis).includes(f.axis))return false;return true})}
function render(){renderSummary();const t=state.tab;if(t==='overview')overview();if(t==='timeline')timeline();if(t==='edca_sites')sites();if(t==='actors')actors();if(t==='narratives')narratives();if(t==='counter_narratives')counters();if(t==='risks')risks();if(t==='search')search();}
function renderSummary(){const d=DB.data;const rows=[['EDCA地点',arr(d.edca_sites).length],['事件',arr(d.events).length],['人物组织',arr(d.actors).length],['叙事结构',arr(d.narratives).length],['竞争叙事',arr(d.counter_narratives).length],['风险项',arr(d.risks).length],['高风险地点',arr(d.edca_sites).filter(x=>x.risk_level==='high').length],['高风险事件',arr(d.events).filter(x=>x.risk_level==='high').length]];$('#summaryPanel').innerHTML=rows.map(([k,v])=>`<div class="summary-item"><strong>${v}</strong><span>${k}</span></div>`).join(''); const tags={}; getAll().forEach(x=>arr(x.tags).forEach(t=>tags[t]=(tags[t]||0)+1)); $('#tagCloud').innerHTML=Object.entries(tags).sort((a,b)=>b[1]-a[1]).slice(0,20).map(([t,n])=>`<span class="pill">${esc(t)} · ${n}</span>`).join('');}
function overview(){const d=DB.data;const high=arr(d.edca_sites).filter(x=>x.risk_level==='high');const eventCats={};arr(d.events).forEach(e=>eventCats[e.category]=(eventCats[e.category]||0)+1); const camps={};arr(d.actors).forEach(a=>camps[a.camp]=(camps[a.camp]||0)+1); $('#main').innerHTML=`<div class="section-title"><div><h2>专题总览</h2><div class="sub">${esc(DB.meta.description)}</div></div><span class="pill red">${esc(DB.meta.version)} · ${esc(DB.meta.last_updated)}</span></div><div class="stats"><div class="stat"><div class="num">${arr(d.edca_sites).length}</div><div class="lab">EDCA地点</div></div><div class="stat"><div class="num">${arr(d.events).length}</div><div class="lab">事件线节点</div></div><div class="stat"><div class="num">${arr(d.actors).length}</div><div class="lab">人物组织</div></div><div class="stat"><div class="num">${high.length}</div><div class="lab">高风险地点</div></div></div><div class="two-col"><div class="panel" style="padding:18px"><h3>核心问题</h3><p>本页面围绕“菲律宾不做美国棋子 / not a US pawn”这一叙事,展示其与EDCA、美菲同盟、Balikatan军演、西菲律宾海主权、杜特尔特—马科斯政治竞争、地方风险和事实核查之间的关系。</p><p class="small">${esc(DB.meta.scope_note||'')}</p>${chips(DB.indexes?.common_tags)}</div><div class="panel" style="padding:18px"><h3>事件类型分布</h3>${Object.entries(eventCats).map(([k,v])=>`<div class="summary-item"><strong>${v}</strong><span>${esc(dict('event_categories',k))}</span></div>`).join('')}</div></div><div style="height:14px"></div><div class="two-col"><div class="panel" style="padding:18px"><h3>阵营分布</h3>${Object.entries(camps).map(([k,v])=>`<div class="summary-item"><strong>${v}</strong><span>${esc(dict('actor_camps',k))}</span></div>`).join('')}</div><div class="panel" style="padding:18px"><h3>展示原则</h3><ul><li>卡片只看摘要,弹框展示完整知识结构。</li><li>长字段按模块呈现,不再显示原始JSON。</li><li>所有关联对象支持点击跳转。</li><li>风险内容同时展示核查重点和边界。</li></ul></div></div><div style="height:14px"></div><div class="section-title"><h2>高风险EDCA地点</h2><button class="inline-btn" onclick="state.tab='edca_sites';renderTabs();render()">查看全部</button></div><div class="cards">${high.map(siteCard).join('')}</div>`}
function timeline(){let items=commonFilter(arr(DB.data.events)).sort((a,b)=>String(b.date).localeCompare(String(a.date))); if(state.filters.province){const ids=arr(DB.data.edca_sites).filter(s=>s.province===state.filters.province).map(s=>s.id);items=items.filter(e=>arr(e.related_sites).some(id=>ids.includes(id))||matchKw(e,state.filters.province));} $('#main').innerHTML=`<div class="section-title"><h2>事件时间线</h2><span class="pill blue">命中 ${items.length} 条</span></div>`+(items.length?`<div class="timeline">${items.map(e=>`<div class="titem"><div class="tcard" onclick="openModal('event','${esc(e.id)}')"><div class="tdate">${esc(e.date)} · ${esc(dict('event_categories',e.category))}</div><h3>${esc(e.title)}</h3><p>${esc(e.summary)}</p><div class="meta-row">${risk(e.risk_level)}${chips(e.tags)}</div></div></div>`).join('')}</div>`:empty())}
function sites(){let items=commonFilter(arr(DB.data.edca_sites)); $('#main').innerHTML=`<div class="section-title"><h2>EDCA九个地点库</h2><span class="pill blue">命中 ${items.length} 条</span></div>${viewbar('sites')} ${state.view==='table'?siteTable(items):`<div class="cards">${items.map(siteCard).join('')}</div>`}`}
function siteCard(s){return `<div class="card" onclick="openModal('edca_site','${esc(s.id)}')"><div class="meta-row"><span class="pill red">${esc(s.province)}</span>${risk(s.risk_level)}</div><h3>${esc(s.name)}</h3><p>${esc(s.brief)}</p><div class="badge-title">战略方向</div><div>${arr(s.strategic_axis).map(a=>`<span class="pill blue">${esc(dict('strategic_axis',a))}</span>`).join('')}</div><div class="badge-title">标签</div>${chips(s.tags)}</div>`}
function siteTable(items){return `<div class="table-wrap"><table><thead><tr><th>地点</th><th>省份</th><th>年份</th><th>类型</th><th>战略方向</th><th>地方态度</th><th>风险</th><th>详情</th></tr></thead><tbody>${items.map(s=>`<tr><td><b>${esc(s.name)}</b><br><span class="small">${esc(s.city_or_area)}</span></td><td>${esc(s.province)}</td><td>${esc(s.designation_year)}</td><td>${esc(dict('site_types',s.site_type))}</td><td>${arr(s.strategic_axis).map(a=>esc(dict('strategic_axis',a))).join('<br>')}</td><td>${esc(s.local_position)}</td><td>${risk(s.risk_level)}</td><td><button class="inline-btn" onclick="openModal('edca_site','${esc(s.id)}')">打开</button></td></tr>`).join('')}</tbody></table></div>`}
function actors(){let items=commonFilter(arr(DB.data.actors)); $('#main').innerHTML=`<div class="section-title"><h2>人物组织库</h2><span class="pill blue">命中 ${items.length} 条</span></div>${viewbar('actors')} ${state.view==='table'?actorTable(items):`<div class="cards">${items.map(actorCard).join('')}</div>`}`}
function actorCard(a){return `<div class="card" onclick="openModal('actor','${esc(a.id)}')"><div class="meta-row"><span class="pill red">${esc(dict('actor_camps',a.camp))}</span><span class="pill">${esc(dict('actor_types',a.type))}</span></div><h3>${esc(a.name_cn||a.name_en)}</h3><p>${esc(a.brief)}</p><div class="badge-title">叙事功能</div><p>${esc(a.narrative_function||'')}</p>${chips(a.tags)}</div>`}
function actorTable(items){return `<div class="table-wrap"><table><thead><tr><th>名称</th><th>类型</th><th>阵营</th><th>当前角色</th><th>EDCA立场</th><th>WPS立场</th><th>影响</th><th>详情</th></tr></thead><tbody>${items.map(a=>`<tr><td><b>${esc(a.name_cn||a.name_en)}</b><br><span class="small">${esc(a.name_en||'')}</span></td><td>${esc(dict('actor_types',a.type))}</td><td>${esc(dict('actor_camps',a.camp))}</td><td>${esc(a.current_role)}</td><td>${esc(dict('stance_values',a.stance_on_edca))}</td><td>${esc(dict('stance_values',a.stance_on_wps))}</td><td>${esc(a.influence_level)}</td><td><button class="inline-btn" onclick="openModal('actor','${esc(a.id)}')">打开</button></td></tr>`).join('')}</tbody></table></div>`}
function narratives(){const items=commonFilter(arr(DB.data.narratives)); $('#main').innerHTML=`<div class="section-title"><h2>叙事结构库</h2><span class="pill blue">命中 ${items.length} 条</span></div><div class="cards">${items.map(n=>`<div class="card" onclick="openModal('narrative','${esc(n.id)}')"><div class="meta-row"><span class="pill red">${esc(n.category)}</span>${risk(n.risk_level)}</div><h3>${esc(n.title)}</h3><p>${esc(n.summary)}</p>${chips(n.tags)}</div>`).join('')}</div>`}
function counters(){const items=commonFilter(arr(DB.data.counter_narratives)); $('#main').innerHTML=`<div class="section-title"><h2>竞争叙事库</h2><span class="pill blue">命中 ${items.length} 条</span></div><div class="cards">${items.map(c=>`<div class="card" onclick="openModal('counter_narrative','${esc(c.id)}')"><div class="meta-row"><span class="pill blue">竞争叙事</span>${risk(c.risk_level)}</div><h3>${esc(c.title)}</h3><p>${esc(c.summary)}</p>${chips(c.tags)}</div>`).join('')}</div>`}
function risks(){const items=commonFilter(arr(DB.data.risks)); const levels=['high','medium','low']; $('#main').innerHTML=`<div class="section-title"><h2>风险与事实核查库</h2><span class="pill blue">命中 ${items.length} 条</span></div><div class="matrix">${levels.map(l=>`<div class="risk-col"><h3>${esc(dict('risk_levels',l))}风险</h3>${items.filter(r=>r.risk_level===l).map(r=>`<div class="card" style="min-height:auto;margin-bottom:10px" onclick="openModal('risk','${esc(r.id)}')"><h3>${esc(r.title)}</h3><p>${esc(r.description)}</p>${chips(r.tags)}</div>`).join('')||'<div class="empty">暂无</div>'}</div>`).join('')}</div>`}
function search(){const kw=state.global||state.filters.keyword||'';let items=getAll().filter(x=>matchKw(x,kw));items=commonFilter(items); const names={event:'事件',edca_site:'EDCA地点',actor:'人物组织',narrative:'叙事结构',counter_narrative:'竞争叙事',risk:'风险'}; $('#main').innerHTML=`<div class="section-title"><div><h2>综合查询中心</h2><div class="sub">${kw?'关键词:'+esc(kw):'可在顶部或左侧输入关键词,跨库检索所有对象。'}</div></div><span class="pill blue">命中 ${items.length} 条</span></div>`+(items.length?`<div class="table-wrap"><table><thead><tr><th>类型</th><th>标题/名称</th><th>摘要</th><th>标签</th><th>风险</th><th>详情</th></tr></thead><tbody>${items.map(i=>`<tr><td><span class="pill red">${esc(names[i.__type])}</span></td><td><b>${esc(titleOf(i))}</b></td><td>${esc(summaryOf(i)).slice(0,260)}</td><td>${chips(i.tags)}</td><td>${risk(i.risk_level)}</td><td><button class="inline-btn" onclick="openModal('${esc(i.__type)}','${esc(i.id)}')">打开</button></td></tr>`).join('')}</tbody></table></div>`:empty('暂无查询结果。'))}
function viewbar(){return `<div class="viewbar"><button class="${state.view==='cards'?'active':''}" onclick="state.view='cards';render()">卡片</button><button class="${state.view==='table'?'active':''}" onclick="state.view='table';render()">表格</button></div>`}
function empty(t='没有匹配结果。'){return `<div class="empty">${esc(t)}</div>`}
function openModal(type,id){const item=find(id); if(!item)return; $('#modalTitle').textContent=titleOf(item); $('#modalMeta').innerHTML=modalMeta(item); $('#modalBody').innerHTML=modalBody(item,type); $('#modalBackdrop').classList.add('show')}
function closeModal(){$('#modalBackdrop').classList.remove('show')}
function backdropClose(e){if(e.target.id==='modalBackdrop')closeModal()}
function modalMeta(o){let bits=[]; if(o.date)bits.push(`<span class="pill">${esc(o.date)}</span>`); if(o.category)bits.push(`<span class="pill blue">${esc(dict('event_categories',o.category))}</span>`); if(o.province)bits.push(`<span class="pill red">${esc(o.province)}</span>`); if(o.camp)bits.push(`<span class="pill red">${esc(dict('actor_camps',o.camp))}</span>`); if(o.risk_level)bits.push(risk(o.risk_level)); return bits.join('')+chips(o.tags)}
function section(title,content,cls=''){if(!content)return''; return `<section class="detail-section ${cls}"><h4>${esc(title)}</h4>${content}</section>`}
function paragraph(v){return v?`<p>${esc(v)}</p>`:''}
function list(v){if(!v||!arr(v).length)return''; return `<ul>${arr(v).map(x=>`<li>${renderVal(x)}</li>`).join('')}</ul>`}
function objGrid(o){if(!o||typeof o!=='object')return''; return `<div class="mini-grid">${Object.entries(o).filter(([k,v])=>v!==undefined&&v!==null&&v!==''&&!(Array.isArray(v)&&!v.length)).map(([k,v])=>`<div class="rel-card"><span>${esc(label(k))}</span><strong>${renderVal(v)}</strong></div>`).join('')}</div>`}
function objTable(items){if(!arr(items).length)return''; if(typeof items[0]!=='object')return list(items); const keys=[...new Set(items.flatMap(o=>Object.keys(o)))]; return `<div class="table-wrap"><table class="obj-table"><thead><tr>${keys.map(k=>`<th>${esc(label(k))}</th>`).join('')}</tr></thead><tbody>${items.map(o=>`<tr>${keys.map(k=>`<td>${renderVal(o[k])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`}
function renderVal(v){if(v===undefined||v===null)return''; if(Array.isArray(v)){if(v.length&&typeof v[0]==='object')return objTable(v); return arr(v).map(x=>renderVal(x)).join('<br>')} if(typeof v==='object')return objGrid(v); const s=String(v); const x=find(s); if(x)return `<span class="linkish pill blue" onclick="jump('${esc(s)}')">${esc(titleOf(x))}</span>`; return esc(s)}
function rels(ids){return arr(ids).map(id=>{const x=find(id); return x?`<div class="rel-card" onclick="jump('${esc(id)}')"><strong>${esc(titleOf(x))}</strong><span>${esc(typeName(x.__type))} · ${esc(summaryOf(x)).slice(0,88)}</span></div>`:`<div class="rel-card"><strong>${esc(id)}</strong></div>`}).join('')}
function typeName(t){return {event:'事件',edca_site:'EDCA地点',actor:'人物组织',narrative:'叙事结构',counter_narrative:'竞争叙事',risk:'风险'}[t]||t}
function distances(d){if(!d)return''; return `<div class="distance-grid">${Object.entries(d).map(([k,v])=>`<div class="distance"><strong>${esc(v)} km</strong><span>${esc(label(k))}</span></div>`).join('')}</div>`}
function modalBody(o,type){
let d=o.detail||{};
if(type==='edca_site') return `<div class="hero"><h3>${esc(o.name)}</h3><p>${esc(o.brief)}</p><div class="kpi-strip"><div class="kpi"><b>${esc(o.designation_year)}</b><span>纳入年份</span></div><div class="kpi"><b>${esc(o.province)}</b><span>省份</span></div><div class="kpi"><b>${esc(dict('site_types',o.site_type))}</b><span>地点类型</span></div><div class="kpi"><b>${esc(dict('risk_levels',o.risk_level))}</b><span>风险等级</span></div></div></div><div class="detail-grid">${section('基本信息',objGrid({province:o.province,city_or_area:o.city_or_area,designation_year:o.designation_year,site_type:dict('site_types',o.site_type),strategic_axis:arr(o.strategic_axis).map(a=>dict('strategic_axis',a)).join(' / '),local_position:o.local_position}),'full')}${section('近似距离与战略方向',distances(o.distances),'full')}${section('战略意义',paragraph(d.strategic_value))}${section('叙事解读',list(d.narrative_interpretation))}${section('地方政治人物表态',objTable(d.local_political_positions),'full')}${section('地方居民抗议或支持',objGrid(d.local_reactions))}${section('基建项目',list(d.infrastructure_projects))}${section('风险争议',list(d.risk_controversies))}${section('官方回应',paragraph(d.official_response))}${section('竞争性解释',list(d.counter_reading))}${section('证据线索',list(d.evidence_clues))}${section('展示提示',`<div class="note-box">${esc(d.display_warning||'')}</div>`)}${section('研究缺口',list(d.research_gaps),'full')}${section('关联事件',rels(d.related_events),'full')}${section('关联人物组织',rels(d.related_actors),'full')}</div>`;
if(type==='event') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.summary)}</p></div><div class="detail-grid">${section('时间地点',objGrid({date:o.date,year:o.year,location:o.location,category:dict('event_categories',o.category)}),'full')}${section('事件背景',paragraph(d.background))}${section('为什么重要',paragraph(d.why_it_matters||o.narrative_signal))}${section('叙事信号',paragraph(o.narrative_signal))}${section('竞争叙事/反驳框架',paragraph(o.counter_narrative))}${section('菲律宾官方立场',paragraph(d.ph_official_position))}${section('美国立场',paragraph(d.us_position))}${section('中国立场',paragraph(d.china_position))}${section('反对方立场',paragraph(d.opposition_position))}${section('叙事复杂性',paragraph(d.narrative_complication))}${section('研究备注',paragraph(d.research_note),'full')}${section('相关行为体',rels(o.actors),'full')}${section('关联地点',rels(o.related_sites),'full')}</div>`;
if(type==='actor') return `<div class="hero"><h3>${esc(o.name_cn||o.name_en)}</h3><p>${esc(o.brief)}</p></div><div class="detail-grid">${section('基本信息',objGrid({name_en:o.name_en,type:dict('actor_types',o.type),camp:dict('actor_camps',o.camp),current_role:o.current_role,stance_on_edca:dict('stance_values',o.stance_on_edca),stance_on_wps:dict('stance_values',o.stance_on_wps),influence_level:o.influence_level}),'full')}${section('人物/组织画像',paragraph(d.profile))}${section('叙事功能',paragraph(o.narrative_function))}${section('典型立场',list(d.typical_positions))}${section('典型关键词',list(d.typical_keywords))}${section('监测重点',list(d.monitoring_focus),'full')}${section('风险备注',paragraph(d.risk_note))}${section('研究备注',paragraph(d.research_note))}${section('关联事件',rels(d.related_events),'full')}${section('关联地点',rels(d.related_sites),'full')}</div>`;
if(type==='narrative') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.summary)}</p></div><div class="detail-grid">${section('核心命题',list(o.core_claims),'full')}${section('目标受众',list(o.target_audiences))}${section('典型表达',list(o.typical_expressions))}${section('适用场景',list(d.application_scenarios))}${section('叙事强项',paragraph(d.strength))}${section('叙事弱项',paragraph(d.weakness))}${section('分析备注',paragraph(d.analysis_note),'full')}${section('待补数据',list(d.data_to_collect))}${section('关联事件',rels(o.related_events),'full')}${section('关联人物组织',rels(o.related_actors),'full')}${section('竞争叙事',rels(d.counter_narratives),'full')}</div>`;
if(type==='counter_narrative') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.summary)}</p></div><div class="detail-grid">${section('主要提出者',list(o.main_speakers))}${section('证据类型',list(o.evidence_types))}${section('优势',paragraph(d.advantage))}${section('限制',paragraph(d.limitation))}${section('观察场景',list(d.where_to_observe))}${section('对“美国棋子”叙事的影响',paragraph(d.impact_on_proxy_narrative),'full')}${section('用于反驳',rels(o.used_against),'full')}${section('关联事件',rels(d.related_events),'full')}</div>`;
if(type==='risk') return `<div class="hero"><h3>${esc(o.title)}</h3><p>${esc(o.description)}</p></div><div class="detail-grid">${section('触发场景',list(o.trigger_scenarios),'full')}${section('事实核查重点',list(o.fact_check_focus),'full')}${section('风险边界与处理建议',list(o.mitigation_guidelines),'full')}${section('展示提示',`<div class="note-box">${esc(o.display_note||'')}</div>`)}${section('关联地点',rels(o.related_sites),'full')}${section('关联人物组织',rels(o.related_actors),'full')}</div>`;
return section('详情',renderVal(o),'full')
}
function jump(id){const x=find(id); if(!x)return; closeModal(); state.tab=tabsForType[x.__type]||'search'; renderTabs(); render(); setTimeout(()=>openModal(x.__type,id),80)}
function importJson(e){const file=e.target.files[0]; if(!file)return; const r=new FileReader(); r.onload=ev=>{try{DB=JSON.parse(ev.target.result);fillFilters();renderTabs();render();alert('JSON导入成功')}catch(err){alert('JSON解析失败:'+err.message)}}; r.readAsText(file,'utf-8')}
function downloadJson(){const blob=new Blob([JSON.stringify(DB,null,2)],{type:'application/json;charset=utf-8'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download='philippines_not_us_pawn_narrative_db_v2_export.json';a.click();URL.revokeObjectURL(url)}
setup();
</script>
</body>
</html>
+51 -29
View File
@@ -6,10 +6,10 @@
1. 受众目标分析,选出最优 3 个受众并入库。
2. 关键传播节点分析,跨受众去重后选出最优 3 个节点并入库。
3. 受众脆弱点分析并入库。
4. 关键传播节点脆弱点分析并入库。
5. 受众策略分析并入库。
6. 关键传播节点策略分析并入库。
3. 从已入库受众中默认选前 2 个继续,每个生成最多 3 个脆弱点并入库。
4. 从已入库关键传播节点中默认选前 2 个继续,每个生成最多 3 个脆弱点并入库。
5. 对上述选中的每个受众默认生成最多 3 个策略并入库。
6. 对上述选中的每个关键传播节点默认生成最多 3 个策略并入库。
7. 同步前端步骤状态,保证原 `item-aiceh` 页面步骤条可继续识别。
## 技能位置
@@ -41,9 +41,9 @@ skills-data/xdfx-single-agent-full-flow/scripts/xdfx_flow.py
}
```
默认会自动处理登录态:当命令没有传 `Authorization`,或某个接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`。拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。
默认会自动处理登录态:脚本启动时先 `import scripts/xdfx_auth.py`,用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/auth/login`,请求体为 `{"username":"admin","password":"ch@user"}`。拿到 `access_token` 后写入内部 `Authorization: Bearer ...`,再调用第一步业务接口;不要通过任何命令行参数传 token。
默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;如果部署环境证书链正常,可以追加 `--verify-ssl`。如果已经有可用 token,也可以继续追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`,脚本会优先使用传入 token。
默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;如果部署环境证书链正常,可以追加 `--verify-ssl`。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次。
## 执行命令
@@ -60,30 +60,44 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
`--mode full` 只作为无人值守或排障兜底,不作为默认执行方式;外层工具通常会等长命令结束后才返回输出,无法保证真实逐步反馈。逐步命令共享同一个 `xdfx-flow-state.json`,不会影响 id 传递、断点续跑和最终入库。
如果需要一条命令完成逐步编排,可以使用包装脚本。它内部仍按 step1-step6 调用 `xdfx_flow.py`,并逐行转发反馈:
```bash
python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
每完成一步都会输出一行:
```text
STEP_FEEDBACK {...}
步骤反馈 {...}
```
调用技能的智能体必须在每个 step 命令结束后读取 `STEP_FEEDBACK`,给用户一次反馈,再执行下一条 step 命令。
调用技能的智能体必须在每个 step 命令结束后读取 `步骤反馈`,给用户一次反馈,再执行下一条 step 命令。
每次执行具体操作或调用接口前,脚本会输出:
```text
OP_FEEDBACK {...}
操作反馈 {...}
```
调用技能的智能体必须把 `operation`、`method` 和 `url` 转述给用户,例如:
调用技能的智能体必须把 `操作`、`请求方式` 和 `接口地址` 转述给用户,例如:
```text
正在保存受众目标,调用 POST https://.../api/task/task-action-design。
```
每次保存接口成功拿到 `id` 后,脚本会输出:
```text
入库反馈 {...}
```
调用技能的智能体必须把 `入库对象类型`、`入库对象名称` 和 `返回ID` 转述给用户,例如“已入库受众目标:xxx,designId=123”或“已入库受众脆弱点:xxx,id=456”。
如果报错,脚本会立即输出:
```text
ERROR_FEEDBACK {...}
错误反馈 {...}
```
调用技能的智能体必须立刻告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
@@ -91,10 +105,10 @@ ERROR_FEEDBACK {...}
如果模型 JSON 经过自动提取和修复仍无法解析,脚本会输出:
```text
JSON_PARSE_REVIEW {...}
JSON解析复核 {...}
```
调用技能的智能体必须查看 `responseExcerpt`,判断是否能识别真正 JSON;无法确认时重跑当前步骤或重新调用对应模型接口。
调用技能的智能体必须查看 `响应片段`,判断是否能识别真正 JSON;无法确认时重跑当前步骤或重新调用对应模型接口。
只检查脚本与参数、不调用真实接口:
@@ -130,6 +144,8 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
- `task-action-design-strategy-relation`
- `task/task-action/{actionId}` 的 `step` 字段
策略信息形式/模态会按前端 `modelType` 字典保存为 value 逗号串,例如模型输出“图片,视频”时,脚本先查 `sys/sys-dict-data` 再写入 `task-action-message.modelType`,不会直接保存中文文字。
## 输出文件
执行后查看:
@@ -139,27 +155,29 @@ xdfx-flow-state.json
xdfx-flow-summary.md
```
`xdfx-flow-state.json` 保存断点状态、接口返回 id、模型解析结果与错误明细。
`xdfx-flow-state.json` 保存断点状态、接口返回 ID、模型解析结果与错误明细。
`xdfx-flow-summary.md` 是给用户看的执行摘要。
## 每步反馈
脚本会输出三类反馈行:
脚本会输出五类反馈行:
- `OP_FEEDBACK`:当前正在做什么、调用哪个接口。
- `ERROR_FEEDBACK`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
- `JSON_PARSE_REVIEW`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
- `STEP_FEEDBACK`:当前步骤最终状态和累计入库数量。
- `操作反馈`:当前正在做什么、调用哪个接口。
- `入库反馈`:当前已入库的对象类型、名称和返回 ID。
- `错误反馈`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
- `JSON解析复核`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
- `步骤反馈`:当前步骤最终状态和累计入库数量。
`STEP_FEEDBACK` 中包含:
`步骤反馈` 中包含:
- `step`:步骤编号。
- `label`:步骤名称。
- `status`:`ok`、`partial` 或 `skipped`。
- `counts`:当前累计入库数量。
- `newErrors`:本步骤新增错误数。
- `totalErrors`:累计错误数。
- `步骤`:步骤编号。
- `步骤名称`:步骤名称。
- `状态`:`成功`、`部分完成` 或 `已跳过`。
- `执行状态`:中文执行状态。
- `累计入库数量`:当前累计入库数量。
- `本步骤新增错误数`:本步骤新增错误数。
- `累计错误数`:累计错误数。
智能体反馈用户时建议使用这种格式:
@@ -167,20 +185,23 @@ xdfx-flow-summary.md
Step1 受众目标分析完成:当前已入库受众 X 个,本步骤新增错误 Y 个。
```
如果 `status=partial`,要补一句“该步骤部分完成”,并说明稍后最终汇总会列出错误明细。
如果 `状态` 为 `部分完成`,要补一句“该步骤部分完成”,并说明稍后最终汇总会列出错误明细。
如果 `status=skipped`,要说明跳过原因。
如果 `状态` 为 `已跳过`,要说明跳过原因。
## 容错规则
- 单个受众、账号、传播节点、脆弱点或策略失败,不中断整个流程。
- 保存接口必须返回 `id`,否则不会进入下游步骤。
- 受众目标默认入库 3 个,关键传播节点默认入库 3 个。
- 下游脆弱点/策略默认只选择前 2 个受众和前 2 个传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
- 每个被选中的对象默认最多生成 3 个脆弱点、3 个策略。
- 模型流式 `answer` 会自动拼接。
- 流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 模型输出会自动去掉 `<think>`,从前后混杂文本、多个 JSON 代码块、无换行流式文本、连续 JSON 对象、非工整 JSON 中提取多个候选,并按当前步骤所需字段选择最像的 JSON。
- 模型 JSON 会尝试修复中文引号、全角冒号/逗号、尾随逗号、未加引号的键、Python 风格 `True/False/None`。
- 模型 JSON 解析失败会自动重试 1 次。
- 每一步完成都会写入 state/summary 并输出 `STEP_FEEDBACK`。
- 每一步完成都会写入 state/summary 并输出 `步骤反馈`。
- 接口失败会写入 `errors`,包含阶段、URL、状态码、请求摘要、响应正文和异常类型。
## 回复用户规则
@@ -190,3 +211,4 @@ Step1 受众目标分析完成:当前已入库受众 X 个,本步骤新增
如果 `errors` 为空,可以说明“全流程完成”。
如果 `errors` 非空,只能说明“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
@@ -26,20 +26,20 @@ scripts/xdfx_flow.py
}
```
默认会在缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 调用:
鉴权不能通过命令行传递 token。脚本会在启动阶段先 `import scripts/xdfx_auth.py`,用 `admin / ch@user` 获取访问令牌,并在第一步业务接口调用前写入请求头。
默认调用:
```text
POST /api/v1/auth/login/username?password=ch%40user
body: {"username":"admin"}
POST /api/auth/login
body: {"username":"admin","password":"ch@user"}
```
拿到 `access_token` 后会写入 `Authorization: Bearer ...` 并重试原接口一次。登录接口和 401 重试都会输出 `OP_FEEDBACK` / `ERROR_FEEDBACK`,但反馈中的 `password`、`token` 会脱敏。默认关闭 HTTPS 证书校验,适配 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。
如果已经有可用登录态,也可以在命令里追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`;此时脚本优先使用传入的 token。
拿到 `access_token` 后会写入内部 `Authorization: Bearer ...`。不要通过任何命令行参数传 token;脚本会忽略外部 Authorization,统一由 `xdfx_auth.py` 获取和刷新。登录接口和 401 重试都会输出 `操作反馈` / `错误反馈`,但反馈中的 `password`、`token` 会脱敏。默认关闭 HTTPS 证书校验,适配 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。
## 执行命令
从本 Skill 目录逐步执行。虽然这是“一个技能做完整流程”的版本,但为了给用户真实的逐步反馈,智能体必须按 step1-step6 分六个命令运行,每个命令结束后先把 `STEP_FEEDBACK` / 新增错误反馈给用户,再继续下一步:
从本 Skill 目录逐步执行。虽然这是“一个技能做完整流程”的版本,但为了给用户真实的逐步反馈,智能体必须按 step1-step6 分六个命令运行,每个命令结束后先把 `步骤反馈` / 新增错误反馈给用户,再继续下一步:
```bash
python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
@@ -52,13 +52,29 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
`--mode full` 仅作为无人值守或本地排障兜底,不作为默认执行方式;因为外层工具通常会等长命令结束才返回输出,无法保证用户看到实时反馈。逐步命令共享同一个 `xdfx-flow-state.json`,id 传递和断点续跑不受影响。
每一步完成后都会输出一行 `STEP_FEEDBACK {...}`。智能体必须在每个 step 命令结束后给用户一次简短反馈,说明该步骤状态、已入库数量和新增错误数,然后再执行下一步。
如果确实想用一条命令编排完整流程,优先使用轻量包装脚本,而不是 `--mode full`。它会在内部逐个启动 step 命令并转发每一行反馈:
脚本每次调用接口前还会输出 `OP_FEEDBACK {...}`,包含当前操作、HTTP 方法和 URL。智能体必须用简短中文告诉用户“正在做什么、调用哪个接口”。
```bash
python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
脚本出现错误时会立即输出 `ERROR_FEEDBACK {...}`,包含阶段、接口 URL、状态码、请求摘要、响应摘要和异常类型。智能体必须立刻告诉用户错误细节,不能等到最后才说。
每一步完成后都会输出一行 `步骤反馈 {...}`。智能体必须在每个 step 命令结束后给用户一次简短反馈,说明该步骤状态、已入库数量和新增错误数,然后再执行下一步。
如果模型返回内容经过多候选 JSON 提取和修复后仍无法解析,脚本会输出 `JSON_PARSE_REVIEW {...}`。智能体必须查看其中的 `responseExcerpt`,判断是否能识别出可用 JSON;无法确认时,重跑当前步骤或重新调用对应接口。
脚本每次调用接口前还会输出 `操作反馈 {...}`,包含当前操作、HTTP 方法和 URL。智能体必须用简短中文告诉用户“正在做什么、调用哪个接口”。
每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`,包含 `入库对象类型`、`入库对象名称`、`返回ID` 和 `接口路径`。智能体必须把它转述成“已入库 xxx 受众 / xxx 脆弱点 / xxx 策略”等明确日志。
脚本出现错误时会立即输出 `错误反馈 {...}`,包含阶段、接口 URL、状态码、请求摘要、响应摘要和异常类型。智能体必须立刻告诉用户错误细节,不能等到最后才说。
如果模型返回内容经过多候选 JSON 提取和修复后仍无法解析,脚本会输出 `JSON解析复核 {...}`。智能体必须查看其中的 `响应片段`,判断是否能识别出可用 JSON;无法确认时,重跑当前步骤或重新调用对应接口。
## 失败重试规则
- 任意步骤出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前失败的同一条 `--mode stepN` 命令。
- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试当前步骤”,不能静默重试。
- 当前步骤未重试成功前,不允许继续执行下一步;不要因为某一步失败就跳过进入后续分析或入库。
- 同一步重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
- 只有用户明确要求忽略该失败,才可以继续后续步骤;回复中必须说明这是用户确认后的带风险继续。
只做参数和脚本检查、不调用接口:
@@ -68,36 +84,44 @@ python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run
## 脚本保证
- 受众只取最优 3 个继续。
- 关键传播节点全局去重后只取最优 3 个继续。
- 受众目标默认入库最优 3 个,可用 `--max-audiences` 调整。
- 关键传播节点全局去重后默认入库最优 3 个,可用 `--max-pathways` 调整。
- 下游脆弱点/策略默认只从已入库结果中选择前 2 个受众、前 2 个传播节点继续,可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
- 每个被选中的受众/传播节点默认生成最多 3 个脆弱点,可用 `--max-vulns` 调整。
- 每个被选中的受众/传播节点默认生成最多 3 个策略,可用 `--max-strategies` 调整。
- 每个保存接口必须拿到返回 `id` 才进入下游。
- 保存后会同步前端 `task/task-action.step` 步骤状态;如只想入库不更新步骤条,追加 `--no-step-status`。
- 策略保存会同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`,兼容策略表格/编排页面读取。
- 策略 `信息形式/模态` 入库前会读取 `sys/sys-dict-data` 的 `modelType` 字典,把模型返回的“图片,视频/文本,图片”等文字转成前端需要的 value 逗号串;字典读取失败时使用内置兜底映射。
- Step1 的受众目标抽取结果里,`用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本会按这个索引依次找到账号并保存受众账号关系。
- 模型流式 `{"answer":"..."}` 会自动拼接。
- 流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 模型输出会自动去掉 `<think>`,从前后混杂文本、多个 JSON 代码块、无换行流式文本、连续 JSON 对象、非工整 JSON 中提取多个候选,并按当前步骤所需字段选择最像的 JSON。
- 模型 JSON 会尝试修复中文引号、全角冒号/逗号、尾随逗号、未加引号的键、Python 风格 `True/False/None`。
- 模型 JSON 解析失败会自动重试 1 次。
- 单个受众、账号、节点或策略失败不会拖垮全流程。
- 每完成一步都会输出 `STEP_FEEDBACK`,包含 `step`、`label`、`status`、累计入库计数和新增错误数。
- 每次接口调用前都会输出 `OP_FEEDBACK`,说明当前操作和接口 URL。
- 每个错误都会立即输出 `ERROR_FEEDBACK`,说明失败阶段、接口、状态码、请求摘要和响应摘要。
- 单个受众、账号、节点或策略失败会被脚本记录;智能体仍必须按“失败重试规则”先重跑当前步骤,不能直接跳到下一步。
- 每完成一步都会输出 `步骤反馈`,包含 `步骤`、`步骤名称`、`状态`、`累计入库数量` 和 `本步骤新增错误数`。
- 每次接口调用前都会输出 `操作反馈`,说明当前操作和接口 URL。
- 每次保存成功都会输出 `入库反馈`,说明已入库对象类型、名称和返回 ID。
- 每个错误都会立即输出 `错误反馈`,说明失败阶段、接口、状态码、请求摘要和响应摘要。
- 所有接口错误都会写入 `xdfx-flow-state.json.errors`,包含阶段、URL、状态码、请求摘要、响应正文和异常类型。
## 回复用户
执行过程中,每出现一行 `STEP_FEEDBACK`,都要立刻向用户反馈一次:
执行过程中,每出现一行 `步骤反馈`,都要立刻向用户反馈一次:
- 当前完成的步骤名称。
- 状态:`ok`、`partial` 或 `skipped`。
- 状态:`成功`、`部分完成` 或 `已跳过`。
- 当前累计入库数量。
- 本步骤新增错误数。
每出现一行 `OP_FEEDBACK`,都要简短告诉用户当前正在执行的操作和接口,例如“正在保存受众目标,调用 POST /api/task/task-action-design”。
每出现一行 `操作反馈`,都要简短告诉用户当前正在执行的操作和接口,例如“正在保存受众目标,调用 POST /api/task/task-action-design”。
每出现一行 `ERROR_FEEDBACK`,都要立刻告诉用户失败阶段、接口 URL、状态码和响应摘要。
每出现一行 `入库反馈`,都要简短告诉用户当前已入库的对象,例如“已入库受众目标:xxx,designId=123”或“已入库受众脆弱点:xxx,id=456”。
每出现一行 `JSON_PARSE_REVIEW`,都要查看 `responseExcerpt`。如果能明显识别出目标 JSON,说明需要人工复核;如果不能识别,重跑当前 step 或重新调用对应模型接口,并告诉用户“模型 JSON 不工整,正在复核并重试”。
每出现一行 `错误反馈`,都要立刻告诉用户失败阶段、接口 URL、状态码和响应摘要。
每出现一行 `JSON解析复核`,都要查看 `响应片段`。如果能明显识别出目标 JSON,说明需要人工复核;如果不能识别,重跑当前 step 或重新调用对应模型接口,并告诉用户“模型 JSON 不工整,正在复核并重试”。
全部执行结束后再读取 `xdfx-flow-summary.md` 和命令输出,向用户说明:
@@ -109,3 +133,4 @@ python scripts/xdfx_flow.py --mode full --input xdfx-input.json --dry-run
- 失败明细
只要 `errors` 不为空,就说“部分完成”,不要说“全部完成”。
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Authentication helper for xdfx skill scripts.
The caller should not pass tokens through command-line arguments. This module
owns the fixed admin login flow and returns a Bearer token before business
interfaces are called.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
@dataclass
class AuthResult:
token: str
url: str
status: int | None
raw: str
class AuthError(RuntimeError):
def __init__(
self,
message: str,
*,
url: str,
status: int | None = None,
response: str = "",
exception: BaseException | None = None,
) -> None:
super().__init__(message)
self.url = url
self.status = status
self.response = response
self.exception = exception
def auth_header_value(token: str) -> str:
token = token.strip()
return token if token.lower().startswith("bearer ") else f"Bearer {token}"
def redact_url(url: str) -> str:
return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
if verify_ssl:
return None
return ssl._create_unverified_context()
def build_login_url(api_base: str, path: str) -> str:
return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
def extract_access_token(data: Any) -> str | None:
if isinstance(data, dict):
for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
for value in data.values():
nested = extract_access_token(value)
if nested:
return nested
elif isinstance(data, list):
for value in data:
nested = extract_access_token(value)
if nested:
return nested
return None
def parse_json_loose(text: str) -> Any:
try:
return json.loads(text)
except Exception:
pass
match = re.search(r"[\[{]", text or "")
if not match:
return {}
start = match.start()
for end in range(len(text), start, -1):
try:
return json.loads(text[start:end])
except Exception:
continue
return {}
def fetch_access_token(
*,
api_base: str,
username: str = "admin",
password: str = "ch@user",
login_path: str = "/api/auth/login",
timeout: float = 90,
verify_ssl: bool = False,
) -> AuthResult:
"""Login once and return the extracted access token."""
url = build_login_url(api_base, login_path)
payload = {"username": username, "password": password}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
raw = resp.read().decode("utf-8", errors="replace")
parsed = parse_json_loose(raw)
token = extract_access_token(parsed)
if not token:
raise AuthError(
"登录接口已响应但未找到 access_token",
url=redact_url(url),
status=getattr(resp, "status", None),
response=raw,
)
return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
raise AuthError(
"登录接口返回错误",
url=redact_url(url),
status=exc.code,
response=body,
exception=exc,
) from exc
except AuthError:
raise
except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
raise AuthError(
"登录接口调用异常",
url=redact_url(url),
response=str(exc),
exception=exc,
) from exc
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Run the xdfx flow one step at a time.
The heavy business logic intentionally stays in xdfx_flow.py. This wrapper only
keeps "full flow" orchestration readable and preserves per-step feedback: every
subprocess line is flushed immediately, so OP/SAVE/ERROR/STEP feedback remains
visible to the calling agent.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from pathlib import Path
STEPS = ("step1", "step2", "step3", "step4", "step5", "step6")
STEP_LABELS = {
"step1": "受众目标分析与入库",
"step2": "关键传播节点分析与入库",
"step3": "受众脆弱点分析与入库",
"step4": "传播节点脆弱点分析与入库",
"step5": "受众策略分析与入库",
"step6": "传播节点策略分析与入库",
}
def build_parser() -> argparse.ArgumentParser:
"""Parse only wrapper-owned flags; pass all other flags to xdfx_flow.py."""
parser = argparse.ArgumentParser(
description="按六个步骤运行 xdfx_flow.py,并实时转发中文反馈日志。",
add_help=True,
)
parser.add_argument("--from-step", choices=STEPS, default="step1")
parser.add_argument("--to-step", choices=STEPS, default="step6")
parser.add_argument(
"--continue-on-error",
action="store_true",
help="某一步返回非零退出码时仍继续执行后续步骤。",
)
return parser
def selected_steps(first: str, last: str) -> list[str]:
"""Return an inclusive step slice for resume/partial reruns."""
start = STEPS.index(first)
end = STEPS.index(last)
if start > end:
raise SystemExit("起始步骤必须小于或等于结束步骤")
return list(STEPS[start : end + 1])
def stream_command(step: str, cmd: list[str]) -> int:
"""Run one step and forward stdout/stderr lines as they are produced."""
print(f"运行步骤:{STEP_LABELS.get(step, step)};执行命令:" + " ".join(cmd), flush=True)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
env=env,
)
assert process.stdout is not None
for line in process.stdout:
sys.stdout.buffer.write(line.encode("utf-8", errors="replace"))
sys.stdout.buffer.flush()
return process.wait()
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args, passthrough = parser.parse_known_args(argv)
flow_script = Path(__file__).with_name("xdfx_flow.py")
exit_code = 0
for step in selected_steps(args.from_step, args.to_step):
# Keep all business flags unchanged so the wrapper behaves like a thin
# streaming version of repeated xdfx_flow.py --mode stepN calls.
cmd = [sys.executable, str(flow_script), "--mode", step, *passthrough]
code = stream_command(step, cmd)
if code:
exit_code = code
if not args.continue_on_error:
return code
return exit_code
if __name__ == "__main__":
raise SystemExit(main())
+34 -13
View File
@@ -28,7 +28,7 @@ scripts/xdfx_flow.py
## 推荐执行顺序
默认会自动处理登录态:当命令没有传 `Authorization`,或某个接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`。拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。默认关闭 HTTPS 证书校验以兼容 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。如果已有可用 token,可追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`。
默认会自动处理登录态:脚本启动时先 `import scripts/xdfx_auth.py`,用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/auth/login`,请求体为 `{"username":"admin","password":"ch@user"}`。拿到 `access_token` 后写入内部 `Authorization: Bearer ...`,再调用第一步业务接口;不要通过任何命令行参数传 token。401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次。默认关闭 HTTPS 证书校验以兼容 UAT/内网自签证书;如果环境证书链正常,可以追加 `--verify-ssl`。
总控按顺序执行下面 6 条命令,所有步骤共用同一个 `xdfx-flow-state.json`:
@@ -41,13 +41,23 @@ python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-fl
python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
每条命令结束都会输出一行 `STEP_FEEDBACK {...}`。总控必须在每个 step 命令结束后立即反馈用户,再继续下一步。
每条命令结束都会输出一行 `步骤反馈 {...}`。总控必须在每个 step 命令结束后立即反馈用户,再继续下一步。
每次接口调用前会输出 `OP_FEEDBACK {...}`。总控必须告诉用户当前正在做什么、调用哪个接口。
每次接口调用前会输出 `操作反馈 {...}`。总控必须告诉用户当前正在做什么、调用哪个接口。
如果接口或模型处理报错,会立即输出 `ERROR_FEEDBACK {...}`。总控必须立刻把失败阶段、URL、状态码、请求摘要和响应摘要告诉用户。
每次保存接口成功拿到返回 ID 后会输出 `入库反馈 {...}`。总控必须告诉用户已入库的对象类型、名称和返回 ID,例如“已入库关键传播节点:xxx,pathwayAccountId=123”。
如果模型返回文本经过自动修复仍无法解析,会输出 `JSON_PARSE_REVIEW {...}`。总控必须查看响应片段,判断是否需要重跑当前 step 或重新调用对应接口。
如果接口或模型处理报错,会立即输出 `错误反馈 {...}`。总控必须立刻把失败阶段、URL、状态码、请求摘要和响应摘要告诉用户。
如果模型返回文本经过自动修复仍无法解析,会输出 `JSON解析复核 {...}`。总控必须查看响应片段,判断是否需要重跑当前 step 或重新调用对应接口。
## 失败重试规则
- 任意 step 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,总控必须先重跑当前失败的同一条 `--mode stepN` 命令。
- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试当前步骤”,不能静默重试。
- 当前 step 未重试成功前,不允许继续执行下一 step;不要因为某一步失败就跳过进入后续分析或入库。
- 同一 step 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
- 只有用户明确要求忽略该失败,才可以继续后续 step;回复中必须说明这是用户确认后的带风险继续。
如果只想一条命令跑完,也可以使用下面命令做无人值守兜底,但不建议作为默认方式,因为外层工具通常会等命令结束后才返回输出,无法保证用户看到实时反馈:
@@ -55,21 +65,32 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
python scripts/xdfx_flow.py --mode full --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
更推荐的一条命令方式是使用包装脚本;它内部仍按 step1-step6 分步调用,并逐行转发反馈:
```bash
python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
## 断点续跑
- Step1 成功后会写入 `topAudiences`。
- Step1 的受众目标抽取结果里,`用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本会按这个索引依次找到账号并保存受众账号关系。
- Step2 成功后会写入 `topPathways`。
- Step3/Step4 会写入两类脆弱点。
- Step5/Step6 会写入两类策略。
- Step1 默认入库最优 3 个受众;Step2 默认入库最优 3 个传播节点。
- Step3/Step4 默认只选择前 2 个已入库受众/传播节点继续,每个生成最多 3 个脆弱点;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
- Step5/Step6 默认对上述选中的每个受众/传播节点生成最多 3 个策略。
- 各步骤会同步前端 `task/task-action.step`;需要禁用时每条命令追加 `--no-step-status`。
- 策略步骤会同步写入 channel、narrate、message、策略与受众/传播节点关系表,保证原前端策略表格/编排页可继续读取。
- 每个步骤完成都会输出 `STEP_FEEDBACK`,总控必须逐步转述给用户。
- 每次操作和接口调用都会输出 `OP_FEEDBACK`,总控必须逐条转述当前操作。
- 每次错误都会输出 `ERROR_FEEDBACK`,总控必须立即报告错误细节。
- 每次 JSON 彻底解析失败都会输出 `JSON_PARSE_REVIEW`,总控必须复核响应片段并判断是否重试当前 step。
- 策略步骤保存 `信息形式/模态` 时,必须使用脚本读取 `sys/sys-dict-data` 的 `modelType` 字典并把文字转成 value 逗号串,不能把“图片,视频”等文字直接写入 `task-action-message.modelType`。
- 每个步骤完成都会输出 `步骤反馈`,总控必须逐步转述给用户。
- 每次操作和接口调用都会输出 `操作反馈`,总控必须逐条转述当前操作。
- 每次保存成功都会输出 `入库反馈`,总控必须逐条转述入库对象。
- 每次错误都会输出 `错误反馈`,总控必须立即报告错误细节。
- 每次 JSON 彻底解析失败都会输出 `JSON解析复核`,总控必须复核响应片段并判断是否重试当前 step。
- 每个步骤里的流式模型接口第一次空响应、返回 `y模型没有响应`/`模型没有响应` 或超时类字符串时,会自动再调用一次;仍失败才写入 `errors`。
- 某一步失败后,修正问题再重跑同一个 `--mode stepN`。
- 某一步失败后,必须先重跑同一个 `--mode stepN`;重试仍失败再等待用户处理或确认,不能自动跳到后续步骤。
## 汇报规则
执行过程中按 `OP_FEEDBACK` 告诉用户当前操作和接口;按 `ERROR_FEEDBACK` 立即报告错误;每个 step 结束后再根据 `STEP_FEEDBACK` 回复用户一次。全部步骤结束后,读取 `xdfx-flow-summary.md` 和 `xdfx-flow-state.json.errors` 再做最终汇总。只要存在错误,就明确说“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
执行过程中按 `操作反馈` 告诉用户当前操作和接口;按 `入库反馈` 告诉用户已入库什么对象和返回 ID;按 `错误反馈` 立即报告错误;每个步骤结束后再根据 `步骤反馈` 回复用户一次。全部步骤结束后,读取 `xdfx-flow-summary.md` 和 `xdfx-flow-state.json.errors` 再做最终汇总。只要存在错误,就明确说“部分完成”,并列出失败阶段、接口 URL、状态码和响应摘要。
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Authentication helper for xdfx skill scripts.
The caller should not pass tokens through command-line arguments. This module
owns the fixed admin login flow and returns a Bearer token before business
interfaces are called.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
@dataclass
class AuthResult:
token: str
url: str
status: int | None
raw: str
class AuthError(RuntimeError):
def __init__(
self,
message: str,
*,
url: str,
status: int | None = None,
response: str = "",
exception: BaseException | None = None,
) -> None:
super().__init__(message)
self.url = url
self.status = status
self.response = response
self.exception = exception
def auth_header_value(token: str) -> str:
token = token.strip()
return token if token.lower().startswith("bearer ") else f"Bearer {token}"
def redact_url(url: str) -> str:
return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
if verify_ssl:
return None
return ssl._create_unverified_context()
def build_login_url(api_base: str, path: str) -> str:
return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
def extract_access_token(data: Any) -> str | None:
if isinstance(data, dict):
for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
for value in data.values():
nested = extract_access_token(value)
if nested:
return nested
elif isinstance(data, list):
for value in data:
nested = extract_access_token(value)
if nested:
return nested
return None
def parse_json_loose(text: str) -> Any:
try:
return json.loads(text)
except Exception:
pass
match = re.search(r"[\[{]", text or "")
if not match:
return {}
start = match.start()
for end in range(len(text), start, -1):
try:
return json.loads(text[start:end])
except Exception:
continue
return {}
def fetch_access_token(
*,
api_base: str,
username: str = "admin",
password: str = "ch@user",
login_path: str = "/api/auth/login",
timeout: float = 90,
verify_ssl: bool = False,
) -> AuthResult:
"""Login once and return the extracted access token."""
url = build_login_url(api_base, login_path)
payload = {"username": username, "password": password}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
raw = resp.read().decode("utf-8", errors="replace")
parsed = parse_json_loose(raw)
token = extract_access_token(parsed)
if not token:
raise AuthError(
"登录接口已响应但未找到 access_token",
url=redact_url(url),
status=getattr(resp, "status", None),
response=raw,
)
return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
raise AuthError(
"登录接口返回错误",
url=redact_url(url),
status=exc.code,
response=body,
exception=exc,
) from exc
except AuthError:
raise
except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
raise AuthError(
"登录接口调用异常",
url=redact_url(url),
response=str(exc),
exception=exc,
) from exc
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Run the xdfx flow one step at a time.
The heavy business logic intentionally stays in xdfx_flow.py. This wrapper only
keeps "full flow" orchestration readable and preserves per-step feedback: every
subprocess line is flushed immediately, so OP/SAVE/ERROR/STEP feedback remains
visible to the calling agent.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from pathlib import Path
STEPS = ("step1", "step2", "step3", "step4", "step5", "step6")
STEP_LABELS = {
"step1": "受众目标分析与入库",
"step2": "关键传播节点分析与入库",
"step3": "受众脆弱点分析与入库",
"step4": "传播节点脆弱点分析与入库",
"step5": "受众策略分析与入库",
"step6": "传播节点策略分析与入库",
}
def build_parser() -> argparse.ArgumentParser:
"""Parse only wrapper-owned flags; pass all other flags to xdfx_flow.py."""
parser = argparse.ArgumentParser(
description="按六个步骤运行 xdfx_flow.py,并实时转发中文反馈日志。",
add_help=True,
)
parser.add_argument("--from-step", choices=STEPS, default="step1")
parser.add_argument("--to-step", choices=STEPS, default="step6")
parser.add_argument(
"--continue-on-error",
action="store_true",
help="某一步返回非零退出码时仍继续执行后续步骤。",
)
return parser
def selected_steps(first: str, last: str) -> list[str]:
"""Return an inclusive step slice for resume/partial reruns."""
start = STEPS.index(first)
end = STEPS.index(last)
if start > end:
raise SystemExit("起始步骤必须小于或等于结束步骤")
return list(STEPS[start : end + 1])
def stream_command(step: str, cmd: list[str]) -> int:
"""Run one step and forward stdout/stderr lines as they are produced."""
print(f"运行步骤:{STEP_LABELS.get(step, step)};执行命令:" + " ".join(cmd), flush=True)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
env=env,
)
assert process.stdout is not None
for line in process.stdout:
sys.stdout.buffer.write(line.encode("utf-8", errors="replace"))
sys.stdout.buffer.flush()
return process.wait()
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args, passthrough = parser.parse_known_args(argv)
flow_script = Path(__file__).with_name("xdfx_flow.py")
exit_code = 0
for step in selected_steps(args.from_step, args.to_step):
# Keep all business flags unchanged so the wrapper behaves like a thin
# streaming version of repeated xdfx_flow.py --mode stepN calls.
cmd = [sys.executable, str(flow_script), "--mode", step, *passthrough]
code = stream_command(step, cmd)
if code:
exit_code = code
if not args.continue_on_error:
return code
return exit_code
if __name__ == "__main__":
raise SystemExit(main())
+48 -23
View File
@@ -45,12 +45,12 @@ scripts/xdfx_flow.py
xdfx-flow-state.json
```
默认会自动处理登录态:缺少 `Authorization` 或接口返回 401 时,脚本会用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/v1/auth/login/username?password=ch%40user`,请求体为 `{"username":"admin"}`,拿到 `access_token` 后写入 `Authorization: Bearer ...` 并重试原接口一次。默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;需要严格校验证书时追加 `--verify-ssl`。已有 token 时可追加 `--auth-token TOKEN` 或 `--header "Authorization: Bearer TOKEN"`。
默认会自动处理登录态:脚本启动时先 `import scripts/xdfx_auth.py`,用固定账号 `admin`、口令 `ch@user` 调用 `POST /api/auth/login`,请求体为 `{"username":"admin","password":"ch@user"}`,拿到 `access_token` 后写入内部 `Authorization: Bearer ...`,再调用第一步业务接口;不要通过任何命令行参数传 token。默认关闭 HTTPS 证书校验,用于兼容 UAT/内网自签证书;需要严格校验证书时追加 `--verify-ssl`。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次。
每一步命令结束都会输出一行:
```text
STEP_FEEDBACK {...}
步骤反馈 {...}
```
总控智能体必须在每一步结束后立即把这行反馈转述给用户,再执行下一步。
@@ -58,15 +58,23 @@ STEP_FEEDBACK {...}
每次执行具体操作或调用接口前,脚本会输出:
```text
OP_FEEDBACK {...}
操作反馈 {...}
```
总控智能体必须把 `operation`、`method` 和 `url` 转述给用户。
总控智能体必须把 `操作`、`请求方式` 和 `接口地址` 转述给用户。
每次保存接口成功拿到 `id` 后,脚本会输出:
```text
入库反馈 {...}
```
总控智能体必须把 `入库对象类型`、`入库对象名称` 和 `返回ID` 转述给用户,例如“已入库受众目标:xxx,designId=123”或“已入库策略:xxx,id=456”。
如果报错,脚本会立即输出:
```text
ERROR_FEEDBACK {...}
错误反馈 {...}
```
总控智能体必须立刻告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
@@ -74,12 +82,12 @@ ERROR_FEEDBACK {...}
如果模型 JSON 经过自动提取和修复仍无法解析,脚本会输出:
```text
JSON_PARSE_REVIEW {...}
JSON解析复核 {...}
```
总控智能体必须查看 `responseExcerpt`,判断是否能识别真正 JSON;无法确认时重跑当前 step 或重新调用对应模型接口。
总控智能体必须查看 `响应片段`,判断是否能识别真正 JSON;无法确认时重跑当前步骤或重新调用对应模型接口。
每一步中的流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,脚本会自动再调用一次;如果第二次仍失败,才写入 `errors` 并在该步骤反馈里体现为 `partial`。
每一步中的流式模型接口第一次返回空响应、`y模型没有响应`、`模型没有响应` 或超时类字符串时,脚本会自动再调用一次;如果第二次仍失败,才写入 `errors` 并在该步骤反馈里体现为 `部分完成`。
## 推荐执行顺序
@@ -96,23 +104,39 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
如果某一步失败,修复接口、鉴权或输入后,只重跑对应 `stepN` 即可。
如果需要一条命令完成分步编排,可以在 `xdfx-split-controller` 目录使用包装脚本;它内部仍按 step1-step6 调用执行器,并逐行转发反馈:
```bash
python scripts/xdfx_run_full_steps.py --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
## 每步反馈格式
脚本会输出三类反馈行:
脚本会输出五类反馈行:
- `OP_FEEDBACK`:当前正在做什么、调用哪个接口。
- `ERROR_FEEDBACK`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
- `JSON_PARSE_REVIEW`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
- `STEP_FEEDBACK`:当前步骤最终状态和累计入库数量。
- `操作反馈`:当前正在做什么、调用哪个接口。
- `入库反馈`:当前已入库对象的类型、名称和返回 ID。
- `错误反馈`:当前错误的阶段、接口、状态码、请求摘要和响应摘要。
- `JSON解析复核`:模型返回内容无法稳定解析为 JSON,需要执行智能体复核是否重试。
- `步骤反馈`:当前步骤最终状态和累计入库数量。
`STEP_FEEDBACK` 中包含:
`步骤反馈` 中包含:
- `step`:步骤编号。
- `label`:步骤名称。
- `status`:`ok`、`partial` 或 `skipped`。
- `counts`:当前累计入库数量。
- `newErrors`:本步骤新增错误数。
- `totalErrors`:累计错误数。
- `步骤`:步骤编号。
- `步骤名称`:步骤名称。
- `状态`:`成功`、`部分完成` 或 `已跳过`。
- `执行状态`:中文执行状态。
- `累计入库数量`:当前累计入库数量。
- `本步骤新增错误数`:本步骤新增错误数。
- `累计错误数`:累计错误数。
默认数量规则:
- Step1 入库最优 3 个受众。
- Step2 入库最优 3 个传播节点。
- Step3/Step4 只从已入库结果中默认选择前 2 个受众/传播节点继续,每个生成最多 3 个脆弱点;可用 `--downstream-audiences` / `--downstream-pathways` 调成 1 或 2。
- Step5/Step6 对上述选中的每个受众/传播节点默认生成最多 3 个策略。
- Step5/Step6 的策略模态必须按前端 `modelType` 字典保存为 value 逗号串;脚本会自动把“图片,视频/文本,图片”等文字转成 `task-action-message.modelType` 需要的值。
总控反馈用户时建议使用这种格式:
@@ -120,9 +144,9 @@ python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-fl
Step2 关键传播节点分析完成:当前已入库传播节点 X 个,本步骤新增错误 Y 个。
```
如果 `status=partial`,说明该步骤部分完成,并继续执行后续可执行步骤。
如果 `状态` 为 `部分完成`,说明该步骤部分完成,并继续执行后续可执行步骤。
如果 `status=skipped`,说明跳过原因。
如果 `状态` 为 `已跳过`,说明跳过原因。
## 各步骤职责
@@ -254,7 +278,7 @@ xdfx-flow-summary.md
xdfx-flow-state.json
```
优先依据本步骤的 `STEP_FEEDBACK` 给即时反馈;最终汇总时再读取完整 summary 和 errors。
优先依据本步骤的 `步骤反馈` 给即时反馈;最终汇总时再读取完整 summary 和 errors。
如果 `errors` 非空,必须告诉用户:
@@ -266,3 +290,4 @@ xdfx-flow-state.json
- 哪些数据已成功入库,哪些数据未完成。
只要存在错误,就说“部分完成”,不要说“全部完成”。
+17 -6
View File
@@ -24,7 +24,7 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl
}
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
@@ -32,7 +32,8 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl
- 自动解析流式 answer 与候选账号。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 抽取结构化受众。
- 选择最优 3 个受众。
- 抽取结果中的 `用户编号列表` / `编号id` / `id` 必须对应上一轮 `zhanghao` 账号数组的索引值;脚本按索引依次找到账号并保存。
- 选择并入库最优 3 个受众。
- 保存受众目标。
- 保存受众目标账号。
- 同步前端步骤状态 `step-analysis-audience` 与 `step-analysis-keypoint`。
@@ -40,12 +41,22 @@ python scripts/xdfx_flow.py --mode step1 --input xdfx-input.json --state xdfx-fl
执行后检查 `xdfx-flow-summary.md`。如果 `errors` 非空,向用户报告失败接口、状态码、响应正文摘要。
## 失败重试规则
- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step1 命令。
- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step1”,不能静默重试。
- Step1 未重试成功前,不允许继续 Step2;不要因为 Step1 失败就跳过受众分析。
- Step1 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step1 的完成状态、当前受众入库数量和新增错误数,再决定是否继续下一步。
命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step1 的完成状态、当前受众入库数量和新增错误数,再决定是否继续下一步。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的受众、受众账号和返回 ID。
如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别受众 JSON;无法确认时重跑 Step1。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众 JSON;无法确认时重跑 Step1。
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Authentication helper for xdfx skill scripts.
The caller should not pass tokens through command-line arguments. This module
owns the fixed admin login flow and returns a Bearer token before business
interfaces are called.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
@dataclass
class AuthResult:
token: str
url: str
status: int | None
raw: str
class AuthError(RuntimeError):
def __init__(
self,
message: str,
*,
url: str,
status: int | None = None,
response: str = "",
exception: BaseException | None = None,
) -> None:
super().__init__(message)
self.url = url
self.status = status
self.response = response
self.exception = exception
def auth_header_value(token: str) -> str:
token = token.strip()
return token if token.lower().startswith("bearer ") else f"Bearer {token}"
def redact_url(url: str) -> str:
return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
if verify_ssl:
return None
return ssl._create_unverified_context()
def build_login_url(api_base: str, path: str) -> str:
return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
def extract_access_token(data: Any) -> str | None:
if isinstance(data, dict):
for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
for value in data.values():
nested = extract_access_token(value)
if nested:
return nested
elif isinstance(data, list):
for value in data:
nested = extract_access_token(value)
if nested:
return nested
return None
def parse_json_loose(text: str) -> Any:
try:
return json.loads(text)
except Exception:
pass
match = re.search(r"[\[{]", text or "")
if not match:
return {}
start = match.start()
for end in range(len(text), start, -1):
try:
return json.loads(text[start:end])
except Exception:
continue
return {}
def fetch_access_token(
*,
api_base: str,
username: str = "admin",
password: str = "ch@user",
login_path: str = "/api/auth/login",
timeout: float = 90,
verify_ssl: bool = False,
) -> AuthResult:
"""Login once and return the extracted access token."""
url = build_login_url(api_base, login_path)
payload = {"username": username, "password": password}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
raw = resp.read().decode("utf-8", errors="replace")
parsed = parse_json_loose(raw)
token = extract_access_token(parsed)
if not token:
raise AuthError(
"登录接口已响应但未找到 access_token",
url=redact_url(url),
status=getattr(resp, "status", None),
response=raw,
)
return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
raise AuthError(
"登录接口返回错误",
url=redact_url(url),
status=exc.code,
response=body,
exception=exc,
) from exc
except AuthError:
raise
except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
raise AuthError(
"登录接口调用异常",
url=redact_url(url),
response=str(exc),
exception=exc,
) from exc
File diff suppressed because it is too large. Load diff
+17 -7
View File
@@ -13,27 +13,37 @@ description: xdfx 拆分流程 step2,基于受众分析关键传播节点,
python scripts/xdfx_flow.py --mode step2 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 对已入库受众逐个分析关键传播节点。
- 自动解析候选账号和传播节点 JSON。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 跨受众合并去重,选择全局最优 3 个传播节点。
- 跨受众合并去重,选择并入库全局最优 3 个传播节点。
- 保存传播节点。
- 保存传播节点与受众关系。
- 同步前端步骤状态 `step-analysis-keypoint` 与 `step-analysis-weakpoint`。
- 把 `pathwayAccountId`、关联 `designIds`、错误明细写入 state。
关系保存失败不阻断后续流程,但必须报告给用户。
关系保存失败会写入错误;智能体必须按失败重试规则先重跑 Step2,不能直接跳到 Step3。
## 失败重试规则
- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step2 命令。
- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step2”,不能静默重试。
- Step2 未重试成功前,不允许继续 Step3;不要因为 Step2 失败就跳过关键传播节点分析。
- Step2 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step2 的完成状态、当前传播节点入库数量和新增错误数,再决定是否继续下一步。
命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step2 的完成状态、当前传播节点入库数量和新增错误数,再决定是否继续下一步。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的传播节点和返回 ID。
如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别传播节点 JSON;无法确认时重跑 Step2。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点 JSON;无法确认时重跑 Step2。
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Authentication helper for xdfx skill scripts.
The caller should not pass tokens through command-line arguments. This module
owns the fixed admin login flow and returns a Bearer token before business
interfaces are called.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
@dataclass
class AuthResult:
token: str
url: str
status: int | None
raw: str
class AuthError(RuntimeError):
def __init__(
self,
message: str,
*,
url: str,
status: int | None = None,
response: str = "",
exception: BaseException | None = None,
) -> None:
super().__init__(message)
self.url = url
self.status = status
self.response = response
self.exception = exception
def auth_header_value(token: str) -> str:
token = token.strip()
return token if token.lower().startswith("bearer ") else f"Bearer {token}"
def redact_url(url: str) -> str:
return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
if verify_ssl:
return None
return ssl._create_unverified_context()
def build_login_url(api_base: str, path: str) -> str:
return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
def extract_access_token(data: Any) -> str | None:
if isinstance(data, dict):
for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
for value in data.values():
nested = extract_access_token(value)
if nested:
return nested
elif isinstance(data, list):
for value in data:
nested = extract_access_token(value)
if nested:
return nested
return None
def parse_json_loose(text: str) -> Any:
try:
return json.loads(text)
except Exception:
pass
match = re.search(r"[\[{]", text or "")
if not match:
return {}
start = match.start()
for end in range(len(text), start, -1):
try:
return json.loads(text[start:end])
except Exception:
continue
return {}
def fetch_access_token(
*,
api_base: str,
username: str = "admin",
password: str = "ch@user",
login_path: str = "/api/auth/login",
timeout: float = 90,
verify_ssl: bool = False,
) -> AuthResult:
"""Login once and return the extracted access token."""
url = build_login_url(api_base, login_path)
payload = {"username": username, "password": password}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
raw = resp.read().decode("utf-8", errors="replace")
parsed = parse_json_loose(raw)
token = extract_access_token(parsed)
if not token:
raise AuthError(
"登录接口已响应但未找到 access_token",
url=redact_url(url),
status=getattr(resp, "status", None),
response=raw,
)
return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
raise AuthError(
"登录接口返回错误",
url=redact_url(url),
status=exc.code,
response=body,
exception=exc,
) from exc
except AuthError:
raise
except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
raise AuthError(
"登录接口调用异常",
url=redact_url(url),
response=str(exc),
exception=exc,
) from exc
File diff suppressed because it is too large. Load diff
+18 -7
View File
@@ -13,26 +13,37 @@ description: xdfx 拆分流程 step3,为已入库受众抽取并保存最多3
python scripts/xdfx_flow.py --mode step3 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 对每个已入库受众抽取最多 3 个脆弱点。
- 从已入库受众中默认选择前 2 个继续分析;可用 `--downstream-audiences 1` 调成只处理 1 个。
- 对每个被选中的受众抽取并入库最多 3 个脆弱点。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动修复和解析模型 JSON。
- 调用受众脆弱点保存接口。
- 只有保存成功并返回 `id` 的脆弱点才进入后续策略阶段。
- 同步前端步骤状态 `step-analysis-weakpoint`。
- 单个受众失败不影响其他受众。
- 单个受众失败会写入错误;智能体必须按失败重试规则先重跑 Step3,不能直接跳到 Step4/Step5。
完成后查看 `audienceVulns` 和 `errors`。
## 失败重试规则
- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step3 命令。
- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step3”,不能静默重试。
- Step3 未重试成功前,不允许继续 Step4/Step5;不要因为 Step3 失败就跳过受众脆弱点分析。
- Step3 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step3 的完成状态、当前受众脆弱点数量和新增错误数,再决定是否继续下一步。
命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step3 的完成状态、当前受众脆弱点数量和新增错误数,再决定是否继续下一步。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的受众脆弱点和返回 ID。
如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别受众脆弱点 JSON;无法确认时重跑 Step3。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众脆弱点 JSON;无法确认时重跑 Step3。
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Authentication helper for xdfx skill scripts.
The caller should not pass tokens through command-line arguments. This module
owns the fixed admin login flow and returns a Bearer token before business
interfaces are called.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
@dataclass
class AuthResult:
token: str
url: str
status: int | None
raw: str
class AuthError(RuntimeError):
def __init__(
self,
message: str,
*,
url: str,
status: int | None = None,
response: str = "",
exception: BaseException | None = None,
) -> None:
super().__init__(message)
self.url = url
self.status = status
self.response = response
self.exception = exception
def auth_header_value(token: str) -> str:
token = token.strip()
return token if token.lower().startswith("bearer ") else f"Bearer {token}"
def redact_url(url: str) -> str:
return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
if verify_ssl:
return None
return ssl._create_unverified_context()
def build_login_url(api_base: str, path: str) -> str:
return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
def extract_access_token(data: Any) -> str | None:
if isinstance(data, dict):
for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
for value in data.values():
nested = extract_access_token(value)
if nested:
return nested
elif isinstance(data, list):
for value in data:
nested = extract_access_token(value)
if nested:
return nested
return None
def parse_json_loose(text: str) -> Any:
try:
return json.loads(text)
except Exception:
pass
match = re.search(r"[\[{]", text or "")
if not match:
return {}
start = match.start()
for end in range(len(text), start, -1):
try:
return json.loads(text[start:end])
except Exception:
continue
return {}
def fetch_access_token(
*,
api_base: str,
username: str = "admin",
password: str = "ch@user",
login_path: str = "/api/auth/login",
timeout: float = 90,
verify_ssl: bool = False,
) -> AuthResult:
"""Login once and return the extracted access token."""
url = build_login_url(api_base, login_path)
payload = {"username": username, "password": password}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
raw = resp.read().decode("utf-8", errors="replace")
parsed = parse_json_loose(raw)
token = extract_access_token(parsed)
if not token:
raise AuthError(
"登录接口已响应但未找到 access_token",
url=redact_url(url),
status=getattr(resp, "status", None),
response=raw,
)
return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
raise AuthError(
"登录接口返回错误",
url=redact_url(url),
status=exc.code,
response=body,
exception=exc,
) from exc
except AuthError:
raise
except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
raise AuthError(
"登录接口调用异常",
url=redact_url(url),
response=str(exc),
exception=exc,
) from exc
File diff suppressed because it is too large. Load diff
+18 -7
View File
@@ -13,26 +13,37 @@ description: xdfx 拆分流程 step4,为已入库关键传播节点抽取并
python scripts/xdfx_flow.py --mode step4 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 对每个已入库关键传播节点抽取最多 3 个脆弱点。
- 从已入库关键传播节点中默认选择前 2 个继续分析;可用 `--downstream-pathways 1` 调成只处理 1 个。
- 对每个被选中的关键传播节点抽取并入库最多 3 个脆弱点。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动修复和解析模型 JSON。
- 调用传播节点脆弱点保存接口。
- 只有保存成功并返回 `id` 的脆弱点才进入后续策略阶段。
- 同步前端步骤状态 `step-analysis-weakpoint`。
- 单个节点失败不影响其他节点。
- 单个节点失败会写入错误;智能体必须按失败重试规则先重跑 Step4,不能直接跳到 Step5/Step6。
完成后查看 `pathwayVulns` 和 `errors`。
## 失败重试规则
- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step4 命令。
- 重试前必须告诉用户失败阶段、接口 URL、状态码、响应摘要和“正在重试 Step4”,不能静默重试。
- Step4 未重试成功前,不允许继续 Step5/Step6;不要因为 Step4 失败就跳过传播节点脆弱点分析。
- Step4 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step4 的完成状态、当前传播节点脆弱点数量和新增错误数,再决定是否继续下一步。
命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step4 的完成状态、当前传播节点脆弱点数量和新增错误数,再决定是否继续下一步。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的传播节点脆弱点和返回 ID。
如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别传播节点脆弱点 JSON;无法确认时重跑 Step4。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点脆弱点 JSON;无法确认时重跑 Step4。
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Authentication helper for xdfx skill scripts.
The caller should not pass tokens through command-line arguments. This module
owns the fixed admin login flow and returns a Bearer token before business
interfaces are called.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
@dataclass
class AuthResult:
token: str
url: str
status: int | None
raw: str
class AuthError(RuntimeError):
def __init__(
self,
message: str,
*,
url: str,
status: int | None = None,
response: str = "",
exception: BaseException | None = None,
) -> None:
super().__init__(message)
self.url = url
self.status = status
self.response = response
self.exception = exception
def auth_header_value(token: str) -> str:
token = token.strip()
return token if token.lower().startswith("bearer ") else f"Bearer {token}"
def redact_url(url: str) -> str:
return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
if verify_ssl:
return None
return ssl._create_unverified_context()
def build_login_url(api_base: str, path: str) -> str:
return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
def extract_access_token(data: Any) -> str | None:
if isinstance(data, dict):
for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
for value in data.values():
nested = extract_access_token(value)
if nested:
return nested
elif isinstance(data, list):
for value in data:
nested = extract_access_token(value)
if nested:
return nested
return None
def parse_json_loose(text: str) -> Any:
try:
return json.loads(text)
except Exception:
pass
match = re.search(r"[\[{]", text or "")
if not match:
return {}
start = match.start()
for end in range(len(text), start, -1):
try:
return json.loads(text[start:end])
except Exception:
continue
return {}
def fetch_access_token(
*,
api_base: str,
username: str = "admin",
password: str = "ch@user",
login_path: str = "/api/auth/login",
timeout: float = 90,
verify_ssl: bool = False,
) -> AuthResult:
"""Login once and return the extracted access token."""
url = build_login_url(api_base, login_path)
payload = {"username": username, "password": password}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
raw = resp.read().decode("utf-8", errors="replace")
parsed = parse_json_loose(raw)
token = extract_access_token(parsed)
if not token:
raise AuthError(
"登录接口已响应但未找到 access_token",
url=redact_url(url),
status=getattr(resp, "status", None),
response=raw,
)
return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
raise AuthError(
"登录接口返回错误",
url=redact_url(url),
status=exc.code,
response=body,
exception=exc,
) from exc
except AuthError:
raise
except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
raise AuthError(
"登录接口调用异常",
url=redact_url(url),
response=str(exc),
exception=exc,
) from exc
File diff suppressed because it is too large. Load diff
@@ -13,27 +13,39 @@ description: xdfx 拆分流程 step5,基于受众目标与受众脆弱点生
python scripts/xdfx_flow.py --mode step5 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 按受众读取已入库脆弱点。
- 从已入库受众中默认选择前 2 个继续生成策略;可用 `--downstream-audiences 1` 调成只处理 1 个。
- 对每个被选中的受众读取已入库脆弱点并默认生成最多 3 个策略。
- 调用策略生成模型接口。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。
- 调用策略保存接口。
- 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`。
- 保存 `task-action-message.modelType` 前会读取前端 `modelType` 字典并把“图片,视频/文本,图片”等模态文字转成 value 逗号串,避免策略表格回显为空。
- 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。
- 保存成功后写入 `audienceStrategies`。
如果某个受众没有可用脆弱点,脚本会跳过并在 `errors` 中记录。
如果某个受众没有可用脆弱点,脚本会记录错误;智能体必须先回到 Step3 或重跑 Step5 修复当前策略环节,不能直接跳过。
## 失败重试规则
- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step5 命令。
- 如果失败原因是缺少受众脆弱点,先重跑 Step3 生成脆弱点,再重跑 Step5;重试前必须告诉用户原因和重试动作。
- Step5 未重试成功前,不允许继续 Step6 或做最终汇总;不要因为 Step5 失败就跳过受众策略分析。
- Step5 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step5 的完成状态、当前受众策略数量和新增错误数,再决定是否继续下一步。
命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step5 的完成状态、当前受众策略数量和新增错误数,再决定是否继续下一步。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的策略、策略子表和返回 ID。
如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别受众策略 JSON;无法确认时重跑 Step5。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别受众策略 JSON;无法确认时重跑 Step5。
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Authentication helper for xdfx skill scripts.
The caller should not pass tokens through command-line arguments. This module
owns the fixed admin login flow and returns a Bearer token before business
interfaces are called.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
@dataclass
class AuthResult:
token: str
url: str
status: int | None
raw: str
class AuthError(RuntimeError):
def __init__(
self,
message: str,
*,
url: str,
status: int | None = None,
response: str = "",
exception: BaseException | None = None,
) -> None:
super().__init__(message)
self.url = url
self.status = status
self.response = response
self.exception = exception
def auth_header_value(token: str) -> str:
token = token.strip()
return token if token.lower().startswith("bearer ") else f"Bearer {token}"
def redact_url(url: str) -> str:
return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
if verify_ssl:
return None
return ssl._create_unverified_context()
def build_login_url(api_base: str, path: str) -> str:
return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
def extract_access_token(data: Any) -> str | None:
if isinstance(data, dict):
for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
for value in data.values():
nested = extract_access_token(value)
if nested:
return nested
elif isinstance(data, list):
for value in data:
nested = extract_access_token(value)
if nested:
return nested
return None
def parse_json_loose(text: str) -> Any:
try:
return json.loads(text)
except Exception:
pass
match = re.search(r"[\[{]", text or "")
if not match:
return {}
start = match.start()
for end in range(len(text), start, -1):
try:
return json.loads(text[start:end])
except Exception:
continue
return {}
def fetch_access_token(
*,
api_base: str,
username: str = "admin",
password: str = "ch@user",
login_path: str = "/api/auth/login",
timeout: float = 90,
verify_ssl: bool = False,
) -> AuthResult:
"""Login once and return the extracted access token."""
url = build_login_url(api_base, login_path)
payload = {"username": username, "password": password}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
raw = resp.read().decode("utf-8", errors="replace")
parsed = parse_json_loose(raw)
token = extract_access_token(parsed)
if not token:
raise AuthError(
"登录接口已响应但未找到 access_token",
url=redact_url(url),
status=getattr(resp, "status", None),
response=raw,
)
return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
raise AuthError(
"登录接口返回错误",
url=redact_url(url),
status=exc.code,
response=body,
exception=exc,
) from exc
except AuthError:
raise
except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
raise AuthError(
"登录接口调用异常",
url=redact_url(url),
response=str(exc),
exception=exc,
) from exc
File diff suppressed because it is too large. Load diff
@@ -13,27 +13,39 @@ description: xdfx 拆分流程 step6,基于关键传播节点与节点脆弱
python scripts/xdfx_flow.py --mode step6 --input xdfx-input.json --state xdfx-flow-state.json --summary xdfx-flow-summary.md
```
鉴权和证书由脚本兜底处理:缺少 `Authorization` 或接口返回 401 时,自动用 `admin / ch@user` 登录获取 `access_token` 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
鉴权和证书由脚本兜底处理:脚本启动时先 `import scripts/xdfx_auth.py`,自动用 `admin / ch@user` 获取 `access_token`,并在第一次业务接口调用前写入内部 Authorization;不要通过命令行传 token。接口返回 401 时仍由 `xdfx_auth.py` 刷新 token 并重试原接口一次;HTTPS 默认不校验证书,需校验证书时追加 `--verify-ssl`。
本步骤由脚本完成:
- 按关键传播节点读取已入库脆弱点。
- 从已入库关键传播节点中默认选择前 2 个继续生成策略;可用 `--downstream-pathways 1` 调成只处理 1 个。
- 对每个被选中的关键传播节点读取已入库脆弱点并默认生成最多 3 个策略。
- 调用策略生成模型接口。
- 流式模型接口首次空响应、模型无响应或超时类返回会自动再调用一次。
- 自动解析 `发布人设N / 账号类型N / 信息标题N` 等字段。
- 调用策略保存接口。
- 同步写入 `task-action-channel`、`base-narrate`、`task-action-message`、`task-action-design-strategy-relation`。
- 保存 `task-action-message.modelType` 前会读取前端 `modelType` 字典并把“图片,视频/文本,图片”等模态文字转成 value 逗号串,避免策略表格回显为空。
- 同步前端步骤状态 `step-analysis-strategy` 与 `step-analysis-strategy-gantt`。
- 保存成功后写入 `pathwayStrategies`。
如果某个传播节点没有可用脆弱点,脚本会跳过并在 `errors` 中记录。
如果某个传播节点没有可用脆弱点,脚本会记录错误;智能体必须先回到 Step4 或重跑 Step6 修复当前策略环节,不能直接跳过。
## 失败重试规则
- 出现 `错误反馈`、`JSON解析复核`,或 `步骤反馈` 中 `状态` 为 `部分完成` / `已跳过` / `本步骤新增错误数` 大于 0 时,必须先重跑当前 Step6 命令。
- 如果失败原因是缺少传播节点脆弱点,先重跑 Step4 生成脆弱点,再重跑 Step6;重试前必须告诉用户原因和重试动作。
- Step6 未重试成功前,不允许做最终成功汇总;不要因为 Step6 失败就跳过传播节点策略分析。
- Step6 重试后仍失败时,停止自动推进,读取 `xdfx-flow-state.json.errors` 汇报失败细节,并等待用户处理或确认。
## 步骤反馈
命令结束会输出 `STEP_FEEDBACK {...}`。必须先根据这行反馈告诉用户 Step6 的完成状态、当前传播节点策略数量和新增错误数,再做最终汇总。
命令结束会输出 `步骤反馈 {...}`。必须先根据这行反馈告诉用户 Step6 的完成状态、当前传播节点策略数量和新增错误数,再做最终汇总。
执行过程中会输出 `OP_FEEDBACK {...}`。每出现一次都要告诉用户当前操作和接口 URL。
执行过程中会输出 `操作反馈 {...}`。每出现一次都要告诉用户当前操作和接口 URL。
如果出现 `ERROR_FEEDBACK {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
保存成功会输出 `入库反馈 {...}`。每出现一次都要告诉用户已入库的策略、策略子表和返回 ID。
如果出现 `错误反馈 {...}`,必须立即告诉用户失败阶段、接口 URL、状态码、请求摘要和响应摘要。
如果出现 `JSON解析复核 {...}`,必须查看 `响应片段`,判断是否能识别传播节点策略 JSON;无法确认时重跑 Step6。
如果出现 `JSON_PARSE_REVIEW {...}`,必须查看 `responseExcerpt`,判断是否能识别传播节点策略 JSON;无法确认时重跑 Step6。
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Authentication helper for xdfx skill scripts.
The caller should not pass tokens through command-line arguments. This module
owns the fixed admin login flow and returns a Bearer token before business
interfaces are called.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
@dataclass
class AuthResult:
token: str
url: str
status: int | None
raw: str
class AuthError(RuntimeError):
def __init__(
self,
message: str,
*,
url: str,
status: int | None = None,
response: str = "",
exception: BaseException | None = None,
) -> None:
super().__init__(message)
self.url = url
self.status = status
self.response = response
self.exception = exception
def auth_header_value(token: str) -> str:
token = token.strip()
return token if token.lower().startswith("bearer ") else f"Bearer {token}"
def redact_url(url: str) -> str:
return re.sub(r"([?&](?:password|token|authToken|access_token)=)[^&]+", r"\1***", url, flags=re.IGNORECASE)
def ssl_context(*, verify_ssl: bool) -> ssl.SSLContext | None:
if verify_ssl:
return None
return ssl._create_unverified_context()
def build_login_url(api_base: str, path: str) -> str:
return path if re.match(r"^https?://", path, flags=re.IGNORECASE) else api_base.rstrip("/") + "/" + path.lstrip("/")
def extract_access_token(data: Any) -> str | None:
if isinstance(data, dict):
for key in ("access_token", "accessToken", "token", "jwt", "id_token", "authorization", "Authorization"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
for value in data.values():
nested = extract_access_token(value)
if nested:
return nested
elif isinstance(data, list):
for value in data:
nested = extract_access_token(value)
if nested:
return nested
return None
def parse_json_loose(text: str) -> Any:
try:
return json.loads(text)
except Exception:
pass
match = re.search(r"[\[{]", text or "")
if not match:
return {}
start = match.start()
for end in range(len(text), start, -1):
try:
return json.loads(text[start:end])
except Exception:
continue
return {}
def fetch_access_token(
*,
api_base: str,
username: str = "admin",
password: str = "ch@user",
login_path: str = "/api/auth/login",
timeout: float = 90,
verify_ssl: bool = False,
) -> AuthResult:
"""Login once and return the extracted access token."""
url = build_login_url(api_base, login_path)
payload = {"username": username, "password": password}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context(verify_ssl=verify_ssl)) as resp:
raw = resp.read().decode("utf-8", errors="replace")
parsed = parse_json_loose(raw)
token = extract_access_token(parsed)
if not token:
raise AuthError(
"登录接口已响应但未找到 access_token",
url=redact_url(url),
status=getattr(resp, "status", None),
response=raw,
)
return AuthResult(token=token, url=redact_url(url), status=getattr(resp, "status", None), raw=raw)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
raise AuthError(
"登录接口返回错误",
url=redact_url(url),
status=exc.code,
response=body,
exception=exc,
) from exc
except AuthError:
raise
except Exception as exc: # noqa: BLE001 - auth errors must preserve detail for user feedback.
raise AuthError(
"登录接口调用异常",
url=redact_url(url),
response=str(exc),
exception=exc,
) from exc
File diff suppressed because it is too large. Load diff
+80
View File
@@ -0,0 +1,80 @@
{
"api_base": "https://ch1.b.uat.4.cn",
"taskId": "1",
"actionId": "2",
"mubiao": "索引映射验证",
"direction": "U方向",
"platform": "",
"audienceRawText": "{\"dryRun\": true}",
"audienceCandidates": [],
"topAudiences": [],
"pathwayCandidates": [],
"topPathways": [],
"audienceVulns": {},
"pathwayVulns": {},
"audienceStrategies": [],
"pathwayStrategies": [],
"errors": [],
"events": [
{
"time": "2026-06-29T08:49:10.660975+00:00",
"stage": "runner",
"message": "start mode=step1 dry_run=True",
"data": null
},
{
"time": "2026-06-29T08:49:10.662968+00:00",
"stage": "step1-audience/analyze/model-call-attempt-1",
"message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
"data": {
"payload": {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
"otherinfo": {
"方向": "U方向"
},
"history": [],
"stream": true,
"model_name": "OpenAI3",
"temperature": 0.7,
"max_tokens": 20000,
"prompt_name": "任务背景和目的:索引映射验证"
}
}
},
{
"time": "2026-06-29T08:49:10.668998+00:00",
"stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
"message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
"data": {
"payload": {
"mubiao": "索引映射验证",
"user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": [],
"history": [],
"stream": true,
"model_name": "OpenAI3",
"temperature": 0.7,
"max_tokens": 20000,
"prompt_name": "索引映射验证"
}
}
},
{
"time": "2026-06-29T08:49:10.668998+00:00",
"stage": "step1-audience",
"message": "完成,保存受众 0 个",
"data": null
}
],
"startedAt": "2026-06-29T08:49:10.659972+00:00",
"updatedAt": "2026-06-29T08:49:10.674982+00:00",
"_statePath": "C:\\tmp\\xdfx-auth-index-check-state.json",
"_summaryPath": "C:\\tmp\\xdfx-auth-index-check-summary.md",
"_autoLogin": true,
"_verifySsl": false,
"_downstreamLimits": {
"audiences": 2,
"pathways": 2
},
"audienceAccounts": []
}
+18
View File
@@ -0,0 +1,18 @@
# xdfx 分析入库执行摘要
- 更新时间:2026-06-29T08:49:10.674982+00:00
- taskId:1
- actionId:2
- 受众入库:0
- 传播节点入库:0
- 下游受众选择:0
- 下游传播节点选择:0
- 受众脆弱点入库:0
- 传播节点脆弱点入库:0
- 受众策略入库:0
- 传播节点策略入库:0
- 错误数:0
## 受众
## 传播节点
+88
View File
@@ -0,0 +1,88 @@
{
"api_base": "https://ch1.b.uat.4.cn",
"taskId": "1",
"actionId": "2",
"mubiao": "日志中文化验证",
"direction": "U方向",
"platform": "",
"audienceRawText": "{\"dryRun\": true}",
"audienceCandidates": [],
"topAudiences": [],
"pathwayCandidates": [],
"topPathways": [],
"audienceVulns": {},
"pathwayVulns": {},
"audienceStrategies": [],
"pathwayStrategies": [],
"errors": [],
"events": [
{
"time": "2026-06-29T07:16:23.252171+00:00",
"stage": "runner",
"message": "start mode=step1 dry_run=True",
"data": null
},
{
"time": "2026-06-29T07:16:23.252171+00:00",
"stage": "runner/auto-login",
"message": "dry-run 跳过自动登录",
"data": {
"username": "admin"
}
},
{
"time": "2026-06-29T07:16:23.253180+00:00",
"stage": "step1-audience/analyze/model-call-attempt-1",
"message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
"data": {
"payload": {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
"otherinfo": {
"方向": "U方向"
},
"history": [],
"stream": true,
"model_name": "OpenAI3",
"temperature": 0.7,
"max_tokens": 20000,
"prompt_name": "任务背景和目的:日志中文化验证"
}
}
},
{
"time": "2026-06-29T07:16:23.261131+00:00",
"stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
"message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
"data": {
"payload": {
"mubiao": "日志中文化验证",
"user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
"zhanghao": [],
"history": [],
"stream": true,
"model_name": "OpenAI3",
"temperature": 0.7,
"max_tokens": 20000,
"prompt_name": "日志中文化验证"
}
}
},
{
"time": "2026-06-29T07:16:23.261131+00:00",
"stage": "step1-audience",
"message": "完成,保存受众 0 个",
"data": null
}
],
"startedAt": "2026-06-29T07:16:23.252171+00:00",
"updatedAt": "2026-06-29T07:16:23.266115+00:00",
"_statePath": "C:\\tmp\\xdfx-log-check-state.json",
"_summaryPath": "C:\\tmp\\xdfx-log-check-summary.md",
"_autoLogin": true,
"_verifySsl": false,
"_downstreamLimits": {
"audiences": 2,
"pathways": 2
},
"audienceAccounts": []
}
+18
View File
@@ -0,0 +1,18 @@
# xdfx 分析入库执行摘要
- 更新时间:2026-06-29T07:16:23.266115+00:00
- taskId:1
- actionId:2
- 受众入库:0
- 传播节点入库:0
- 下游受众选择:0
- 下游传播节点选择:0
- 受众脆弱点入库:0
- 传播节点脆弱点入库:0
- 受众策略入库:0
- 传播节点策略入库:0
- 错误数:0
## 受众
## 传播节点
+80
View File
@@ -0,0 +1,80 @@
{
"api_base": "https://ch1.b.uat.4.cn",
"taskId": "dry-task",
"actionId": "dry-action",
"mubiao": "登录修正演练",
"direction": "U方向",
"platform": "",
"audienceRawText": "{\"dryRun\": true}",
"audienceCandidates": [],
"topAudiences": [],
"pathwayCandidates": [],
"topPathways": [],
"audienceVulns": {},
"pathwayVulns": {},
"audienceStrategies": [],
"pathwayStrategies": [],
"errors": [],
"events": [
{
"time": "2026-06-29T09:58:41.695868+00:00",
"stage": "runner",
"message": "start mode=step1 dry_run=True",
"data": null
},
{
"time": "2026-06-29T09:58:41.697959+00:00",
"stage": "step1-audience/analyze/model-call-attempt-1",
"message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
"data": {
"payload": {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
"otherinfo": {
"方向": "U方向"
},
"history": [],
"stream": true,
"model_name": "OpenAI3",
"temperature": 0.7,
"max_tokens": 20000,
"prompt_name": "任务背景和目的:登录修正演练"
}
}
},
{
"time": "2026-06-29T09:58:41.703207+00:00",
"stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
"message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
"data": {
"payload": {
"mubiao": "登录修正演练",
"user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体。字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。用户编号列表中的编号/id必须对应本次入参 zhanghao 数组里的账号索引值;例如返回 0 或 <<0>> 表示 zhanghao[0],返回 1 或 <<1>> 表示 zhanghao[1]。不要生成不存在于 zhanghao 的编号。",
"zhanghao": [],
"history": [],
"stream": true,
"model_name": "OpenAI3",
"temperature": 0.7,
"max_tokens": 20000,
"prompt_name": "登录修正演练"
}
}
},
{
"time": "2026-06-29T09:58:41.703207+00:00",
"stage": "step1-audience",
"message": "完成,保存受众 0 个",
"data": null
}
],
"startedAt": "2026-06-29T09:58:41.694826+00:00",
"updatedAt": "2026-06-29T09:58:41.709191+00:00",
"_statePath": "C:\\tmp\\xdfx-login-fix-state.json",
"_summaryPath": "C:\\tmp\\xdfx-login-fix-summary.md",
"_autoLogin": true,
"_verifySsl": false,
"_downstreamLimits": {
"audiences": 2,
"pathways": 2
},
"audienceAccounts": []
}
+18
View File
@@ -0,0 +1,18 @@
# xdfx 分析入库执行摘要
- 更新时间:2026-06-29T09:58:41.709191+00:00
- taskId:dry-task
- actionId:dry-action
- 受众入库:0
- 传播节点入库:0
- 下游受众选择:0
- 下游传播节点选择:0
- 受众脆弱点入库:0
- 传播节点脆弱点入库:0
- 受众策略入库:0
- 传播节点策略入库:0
- 错误数:0
## 受众
## 传播节点
+88
View File
@@ -0,0 +1,88 @@
{
"api_base": "https://ch1.b.uat.4.cn",
"taskId": "1",
"actionId": "2",
"mubiao": "dry-run-test",
"direction": "U方向",
"platform": "fb",
"audienceRawText": "{\"dryRun\": true}",
"audienceCandidates": [],
"topAudiences": [],
"pathwayCandidates": [],
"topPathways": [],
"audienceVulns": {},
"pathwayVulns": {},
"audienceStrategies": [],
"pathwayStrategies": [],
"errors": [],
"events": [
{
"time": "2026-06-29T03:43:58.007253+00:00",
"stage": "runner",
"message": "start mode=step1 dry_run=True",
"data": null
},
{
"time": "2026-06-29T03:43:58.007253+00:00",
"stage": "runner/auto-login",
"message": "dry-run 跳过自动登录",
"data": {
"username": "admin"
}
},
{
"time": "2026-06-29T03:43:58.007253+00:00",
"stage": "step1-audience/analyze/model-call-attempt-1",
"message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_aiuser",
"data": {
"payload": {
"usermessage": "你是中国对台湾方向的认知域专家,你要围绕驱动目标传播行动中的叙事主题,请详细分析你在各社交平台的受众目标群体,可以从账号画像和账号近期发文等方面考虑。可以从受众目标的兴趣爱好、关注议题、职业定位、政治立场等方面划分。",
"otherinfo": {
"方向": "U方向"
},
"history": [],
"stream": true,
"model_name": "OpenAI3",
"temperature": 0.7,
"max_tokens": 20000,
"prompt_name": "任务背景和目的:dry-run-test"
}
}
},
{
"time": "2026-06-29T03:43:58.017225+00:00",
"stage": "step1-audience/extract/model-json-attempt-1/model-call-attempt-1",
"message": "DRY RUN POST https://ch1.b.uat.4.cn/gpt/chat/chat_qianqian_group_chat",
"data": {
"payload": {
"mubiao": "dry-run-test",
"user_message": "分析其中关键信息。请基于上一轮受众分析结果,抽取受众目标群体,字段必须是:群体1、用户编号列表1、理由1、群体2、用户编号列表2、理由2 ...,最多输出5个,只返回JSON。",
"zhanghao": [],
"history": [],
"stream": true,
"model_name": "OpenAI3",
"temperature": 0.7,
"max_tokens": 20000,
"prompt_name": "dry-run-test"
}
}
},
{
"time": "2026-06-29T03:43:58.018223+00:00",
"stage": "step1-audience",
"message": "完成,保存受众 0 个",
"data": null
}
],
"startedAt": "2026-06-29T03:43:58.006257+00:00",
"updatedAt": "2026-06-29T03:43:58.021216+00:00",
"_statePath": "C:\\tmp\\xdfx-runner-dry-state.json",
"_summaryPath": "C:\\tmp\\xdfx-runner-dry-summary.md",
"_autoLogin": true,
"_verifySsl": false,
"_downstreamLimits": {
"audiences": 2,
"pathways": 2
},
"audienceAccounts": []
}
+18
View File
@@ -0,0 +1,18 @@
# xdfx 分析入库执行摘要
- 更新时间:2026-06-29T03:43:58.021216+00:00
- taskId:1
- actionId:2
- 受众入库:0
- 传播节点入库:0
- 下游受众选择:0
- 下游传播节点选择:0
- 受众脆弱点入库:0
- 传播节点脆弱点入库:0
- 受众策略入库:0
- 传播节点策略入库:0
- 错误数:0
## 受众
## 传播节点