11666 lines
560 KiB
JavaScript
11666 lines
560 KiB
JavaScript
const STORAGE_KEY = "storyforge-web-v4-session";
|
||
const SESSION_STORE = StoryForgeSessionStore.create(STORAGE_KEY);
|
||
const DEFAULT_BACKEND_URL = StoryForgeApiClient.detectDefaultBackendUrl();
|
||
const RECOVERY_HISTORY_KEY = STORAGE_KEY + ":recovery-history";
|
||
|
||
const navButtons = document.querySelectorAll("[data-screen-target]");
|
||
const screens = Array.from(document.querySelectorAll("[data-screen]"));
|
||
const screenMap = Object.fromEntries(screens.map((screen) => [screen.dataset.screen, screen]));
|
||
const mobileScreenTitle = document.querySelector('[data-role="mobile-screen-title"]');
|
||
const mobileProjectTitle = document.querySelector('[data-role="mobile-project-title"]');
|
||
const mobileShellStatus = document.querySelector(".mobile-shell-status");
|
||
|
||
const SCREEN_LABELS = {
|
||
dashboard: "项目总台",
|
||
intake: "我的项目",
|
||
discovery: "找对标",
|
||
tracking: "跟踪账号",
|
||
owned: "我的账号",
|
||
playbook: "Agent",
|
||
strategy: "我的策略",
|
||
production: "生产中心",
|
||
review: "发布与复盘",
|
||
automation: "自动流程",
|
||
credits: "额度",
|
||
settings: "设置",
|
||
"admin-workbench": "管理员配置台"
|
||
};
|
||
|
||
const appState = {
|
||
screen: window.location.hash.replace("#", "") || "dashboard",
|
||
session: SESSION_STORE.loadStoredSession(),
|
||
me: null,
|
||
dashboard: null,
|
||
contentSources: [],
|
||
accounts: [],
|
||
selectedAccountId: "",
|
||
selectedAccountRequestToken: 0,
|
||
selectedWorkspace: null,
|
||
selectedVideos: { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 },
|
||
snapshots: [],
|
||
selectedSnapshotId: "",
|
||
selectedSnapshotDetail: null,
|
||
creatorFields: null,
|
||
analysisReports: [],
|
||
documents: [],
|
||
discoveryQuery: "",
|
||
currentPlatform: localStorage.getItem(STORAGE_KEY + ":currentPlatform") || "",
|
||
selectedProjectId: "",
|
||
dashboardOverviewTab: "project_progress",
|
||
discoveryDetailTab: "overview",
|
||
playbookDetailTab: "workspace",
|
||
strategyDetailTab: "effective",
|
||
productionDetailTab: "queue",
|
||
automationDetailTab: "health",
|
||
adminWorkbenchTab: "integrations",
|
||
settingsDetailTab: "workspace",
|
||
selectedAssistantId: "",
|
||
lastSeenAt: SESSION_STORE.getLastSeenAt(Date.now()),
|
||
trackingCursorMap: {},
|
||
trackingAccounts: [],
|
||
trackingDigest: null,
|
||
trackingRefreshNotice: null,
|
||
reviews: [],
|
||
reviewFocusId: "",
|
||
liveRecorderSources: [],
|
||
liveRecorderStatus: null,
|
||
liveRecorderFiles: [],
|
||
liveRecorderHealth: null,
|
||
storageStatus: null,
|
||
integrationHealth: null,
|
||
localModelCatalog: null,
|
||
backendCapabilities: null,
|
||
onelinerProfile: null,
|
||
onelinerSessions: [],
|
||
selectedOnelinerSessionId: "",
|
||
onelinerRuns: [],
|
||
onelinerRunFilter: "focus",
|
||
selectedOnelinerRunId: "",
|
||
lastCompletedOnelinerRunId: "",
|
||
onelinerMessages: [],
|
||
onelinerActionRegistry: [],
|
||
platformAgents: [],
|
||
onelinerGovernanceEffective: null,
|
||
userGlobalPolicy: null,
|
||
userCurrentPlatformPolicy: null,
|
||
userPolicyAudits: [],
|
||
adminSystemMainPolicy: null,
|
||
adminSystemPlatformPolicies: [],
|
||
adminGovernanceDirectory: null,
|
||
adminOverrideTarget: null,
|
||
adminOverridePolicy: null,
|
||
adminPolicyAudits: [],
|
||
tenantQuota: null,
|
||
tenantUsage: null,
|
||
adminOpsOverview: null,
|
||
adminFixRuns: [],
|
||
recoveryRecords: [],
|
||
autoConnectAttempted: false,
|
||
autoConnectSuppressed: false,
|
||
autoConnectError: "",
|
||
dashboardActionReason: null,
|
||
busy: false,
|
||
message: "",
|
||
lastAction: null,
|
||
mainAgentLanding: null,
|
||
lastGeneratedCopy: null,
|
||
lastSimilaritySearch: null,
|
||
lastJobDetail: null,
|
||
topVideoAnalysisResults: {}
|
||
};
|
||
|
||
let PLATFORM_RUNTIME = null;
|
||
let onelinerRunPollTimer = null;
|
||
|
||
const API_CLIENT = StoryForgeApiClient.create({
|
||
getSession: () => appState.session,
|
||
defaultBackendUrl: DEFAULT_BACKEND_URL,
|
||
getCapabilities: () => appState.backendCapabilities
|
||
});
|
||
|
||
const INTEGRATION_ORDER = ["local_model", "live_recorder", "cutvideo", "huobao", "n8n", "asr"];
|
||
const ACTIVE_PLATFORMS = [
|
||
{ value: "douyin", label: "抖音" },
|
||
{ value: "xiaohongshu", label: "小红书" },
|
||
{ value: "bilibili", label: "哔哩哔哩" },
|
||
{ value: "kuaishou", label: "快手" },
|
||
{ value: "wechat_video", label: "微信视频号" }
|
||
];
|
||
const makePlatformRoutes = StoryForgePlatformRuntime.makePlatformRoutes;
|
||
|
||
const PLATFORM_REGISTRY = {
|
||
douyin: {
|
||
label: "抖音",
|
||
shortLabel: "抖音",
|
||
workbenchReady: true,
|
||
routes: makePlatformRoutes("douyin")
|
||
},
|
||
xiaohongshu: {
|
||
label: "小红书",
|
||
shortLabel: "小红书",
|
||
workbenchReady: true,
|
||
routes: makePlatformRoutes("xiaohongshu")
|
||
},
|
||
bilibili: {
|
||
label: "哔哩哔哩",
|
||
shortLabel: "B站",
|
||
workbenchReady: true,
|
||
routes: makePlatformRoutes("bilibili")
|
||
},
|
||
kuaishou: {
|
||
label: "快手",
|
||
shortLabel: "快手",
|
||
workbenchReady: true,
|
||
routes: makePlatformRoutes("kuaishou")
|
||
},
|
||
wechat_video: {
|
||
label: "微信视频号",
|
||
shortLabel: "视频号",
|
||
workbenchReady: true,
|
||
routes: makePlatformRoutes("wechat_video")
|
||
}
|
||
};
|
||
|
||
PLATFORM_RUNTIME = StoryForgePlatformRuntime.create({
|
||
activePlatforms: ACTIVE_PLATFORMS,
|
||
platformRegistry: PLATFORM_REGISTRY,
|
||
appState,
|
||
storage: localStorage,
|
||
storageKey: STORAGE_KEY
|
||
});
|
||
|
||
const INTEGRATION_META = {
|
||
local_model: {
|
||
label: "本机模型",
|
||
hint: "OpenAI-compatible",
|
||
impacts: ["账号分析", "高分分析", "文案生成"]
|
||
},
|
||
live_recorder: {
|
||
label: "直播录制",
|
||
hint: "fnOS NAS",
|
||
impacts: ["直播源导入", "录制控制"]
|
||
},
|
||
cutvideo: {
|
||
label: "自动剪辑",
|
||
hint: "cutvideo 直连",
|
||
impacts: ["实拍剪辑"]
|
||
},
|
||
huobao: {
|
||
label: "AI 视频",
|
||
hint: "huobao-drama",
|
||
impacts: ["AI 视频"]
|
||
},
|
||
n8n: {
|
||
label: "编排",
|
||
hint: "n8n workflow",
|
||
impacts: ["AI 视频", "实拍剪辑", "自动链路"]
|
||
},
|
||
asr: {
|
||
label: "ASR",
|
||
hint: "素材转写",
|
||
impacts: ["分析转写"]
|
||
}
|
||
};
|
||
const PIPELINE_GUARDS = {
|
||
aiVideo: {
|
||
label: "AI 视频",
|
||
openAction: "open-ai-video",
|
||
jobAction: "job-to-ai-video",
|
||
dependencies: ["n8n", "huobao"]
|
||
},
|
||
realCut: {
|
||
label: "实拍剪辑",
|
||
openAction: "open-real-cut",
|
||
jobAction: "job-to-real-cut",
|
||
dependencies: ["n8n", "cutvideo"]
|
||
}
|
||
};
|
||
|
||
const ONELINER_INTENT_LABELS = {
|
||
create_project: "创建项目",
|
||
create_assistant: "创建 Agent",
|
||
import_homepage: "导入主页",
|
||
track_account: "跟踪账号",
|
||
analyze_account: "分析账号",
|
||
analyze_top_videos: "分析高分作品",
|
||
generate_copy: "生成文案",
|
||
ai_video: "AI 视频",
|
||
real_cut: "实拍剪辑",
|
||
review: "发布复盘",
|
||
live_recorder: "直播录制",
|
||
storage_status: "存储状态",
|
||
ops_admin: "运维巡检",
|
||
custom: "自定义任务"
|
||
};
|
||
|
||
function safeArray(value) {
|
||
return Array.isArray(value) ? value : [];
|
||
}
|
||
|
||
function parseJsonSafe(value, fallback) {
|
||
if (typeof value !== "string" || !value.trim()) return fallback;
|
||
try {
|
||
return JSON.parse(value);
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function getRuntimePlatformValues() {
|
||
return PLATFORM_RUNTIME.getRuntimePlatformValues();
|
||
}
|
||
|
||
function getPlatformOptions() {
|
||
return PLATFORM_RUNTIME.getPlatformOptions();
|
||
}
|
||
|
||
function normalizePlatformValue(value, fallback = "douyin") {
|
||
return PLATFORM_RUNTIME.normalizePlatformValue(value, fallback);
|
||
}
|
||
|
||
function platformLabel(value) {
|
||
return PLATFORM_RUNTIME.platformLabel(value);
|
||
}
|
||
|
||
function getPlatformMeta(value) {
|
||
return PLATFORM_RUNTIME.getPlatformMeta(value);
|
||
}
|
||
|
||
function getPlatformShortLabel(value) {
|
||
return PLATFORM_RUNTIME.getPlatformShortLabel(value);
|
||
}
|
||
|
||
function getPlatformChips() {
|
||
return PLATFORM_RUNTIME.getPlatformChips();
|
||
}
|
||
|
||
function isWorkbenchPlatform(value) {
|
||
return PLATFORM_RUNTIME.isWorkbenchPlatform(value);
|
||
}
|
||
|
||
function getWorkbenchRoute(platform, key, ...args) {
|
||
return PLATFORM_RUNTIME.getWorkbenchRoute(platform, key, ...args);
|
||
}
|
||
|
||
function setCurrentPlatform(value) {
|
||
return PLATFORM_RUNTIME.setCurrentPlatform(value);
|
||
}
|
||
|
||
function getAccountPlatform(account) {
|
||
return PLATFORM_RUNTIME.getAccountPlatform(account);
|
||
}
|
||
|
||
function getAccountHandle(account) {
|
||
return PLATFORM_RUNTIME.getAccountHandle(account);
|
||
}
|
||
|
||
function getAccountProfileUrl(account) {
|
||
return PLATFORM_RUNTIME.getAccountProfileUrl(account);
|
||
}
|
||
|
||
function getAccountName(account) {
|
||
return PLATFORM_RUNTIME.getAccountName(account);
|
||
}
|
||
|
||
function getAccountSubtitle(account) {
|
||
return PLATFORM_RUNTIME.getAccountSubtitle(account);
|
||
}
|
||
|
||
function getPendingWorkbenchReason(platform) {
|
||
return PLATFORM_RUNTIME.getPendingWorkbenchReason(platform);
|
||
}
|
||
|
||
function getAccountWorkbenchGate(account) {
|
||
return PLATFORM_RUNTIME.getAccountWorkbenchGate(account);
|
||
}
|
||
|
||
function getPreferredPlatform() {
|
||
return PLATFORM_RUNTIME.getPreferredPlatform();
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function brief(value, max = 88) {
|
||
const text = String(value ?? "").trim();
|
||
if (!text) return "暂无";
|
||
return text.length > max ? text.slice(0, max).trimEnd() + "…" : text;
|
||
}
|
||
|
||
function formatNumber(value) {
|
||
const num = Number(value || 0);
|
||
if (!Number.isFinite(num)) return "-";
|
||
if (num >= 100000000) return (num / 100000000).toFixed(1).replace(/\.0$/, "") + "亿";
|
||
if (num >= 10000) return (num / 10000).toFixed(1).replace(/\.0$/, "") + "w";
|
||
if (num >= 1000) return num.toLocaleString("zh-CN");
|
||
return String(Math.round(num * 10) / 10);
|
||
}
|
||
|
||
function formatBytes(value) {
|
||
const num = Number(value || 0);
|
||
if (!Number.isFinite(num) || num <= 0) return "0 B";
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let size = num;
|
||
let idx = 0;
|
||
while (size >= 1024 && idx < units.length - 1) {
|
||
size /= 1024;
|
||
idx += 1;
|
||
}
|
||
const fixed = size >= 10 || idx === 0 ? size.toFixed(0) : size.toFixed(1);
|
||
return `${fixed}${units[idx]}`;
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) return "-";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return String(value);
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit"
|
||
}).format(date);
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) return "-";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return String(value);
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
month: "2-digit",
|
||
day: "2-digit"
|
||
}).format(date);
|
||
}
|
||
|
||
function daysSince(value) {
|
||
if (!value) return "-";
|
||
const time = new Date(value).getTime();
|
||
if (!Number.isFinite(time)) return "-";
|
||
const diff = Date.now() - time;
|
||
return Math.max(0, Math.floor(diff / 86400000));
|
||
}
|
||
|
||
function initials(value) {
|
||
const raw = String(value || "").trim();
|
||
if (!raw) return "SF";
|
||
return raw.slice(0, 2).toUpperCase();
|
||
}
|
||
|
||
function statusTone(status) {
|
||
const normalized = String(status || "").toLowerCase();
|
||
if (["completed", "ready", "approved", "ok"].includes(normalized)) return "green";
|
||
if (["failed", "error", "rejected"].includes(normalized)) return "red";
|
||
if (["worth_scaling", "good_reference"].includes(normalized)) return "green";
|
||
if (["needs_rework"].includes(normalized)) return "red";
|
||
if (["hold"].includes(normalized)) return "orange";
|
||
if (["running", "processing", "pending", "queued"].includes(normalized)) return "orange";
|
||
return "blue";
|
||
}
|
||
|
||
function readRecoveryRecords() {
|
||
try {
|
||
const raw = localStorage.getItem(RECOVERY_HISTORY_KEY);
|
||
const parsed = raw ? JSON.parse(raw) : [];
|
||
return safeArray(parsed);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function persistRecoveryRecords(records) {
|
||
try {
|
||
localStorage.setItem(RECOVERY_HISTORY_KEY, JSON.stringify(safeArray(records).slice(0, 40)));
|
||
} catch {}
|
||
}
|
||
|
||
function normalizeRecoveryRecord(record) {
|
||
if (!record || typeof record !== "object") return null;
|
||
return {
|
||
id: String(record.id || `recovery_${Date.now()}`),
|
||
created_at: record.created_at || new Date().toISOString(),
|
||
account_id: String(record.account_id || ""),
|
||
project_id: String(record.project_id || ""),
|
||
job_id: String(record.job_id || ""),
|
||
job_title: String(record.job_title || ""),
|
||
job_line_type: String(record.job_line_type || ""),
|
||
job_source_type: String(record.job_source_type || ""),
|
||
job_status: String(record.job_status || "failed"),
|
||
action_key: String(record.action_key || "retry-job"),
|
||
mode: String(record.mode || "single"),
|
||
summary: String(record.summary || ""),
|
||
reason: String(record.reason || ""),
|
||
target_job_id: String(record.target_job_id || ""),
|
||
target_job_title: String(record.target_job_title || ""),
|
||
target_status: String(record.target_status || ""),
|
||
result_label: String(record.result_label || ""),
|
||
result_reason: String(record.result_reason || ""),
|
||
user_feedback: String(record.user_feedback || "")
|
||
};
|
||
}
|
||
|
||
function getRecoveryRecords() {
|
||
const accountId = String(appState.session?.account?.id || "");
|
||
return readRecoveryRecords()
|
||
.map((item) => normalizeRecoveryRecord(item))
|
||
.filter(Boolean)
|
||
.filter((item) => !accountId || item.account_id === accountId)
|
||
.sort((left, right) => compareDateDesc(left.created_at, right.created_at));
|
||
}
|
||
|
||
function recordRecoveryEvent(record) {
|
||
const normalized = normalizeRecoveryRecord({
|
||
...record,
|
||
account_id: record.account_id || appState.session?.account?.id || "",
|
||
project_id: record.project_id || appState.selectedProjectId || "",
|
||
created_at: record.created_at || new Date().toISOString()
|
||
});
|
||
if (!normalized) return null;
|
||
const nextRecords = [normalized, ...readRecoveryRecords()]
|
||
.filter((item, index, list) => item && list.findIndex((candidate) => candidate.id === item.id) === index)
|
||
.sort((left, right) => compareDateDesc(left.created_at, right.created_at))
|
||
.slice(0, 40);
|
||
persistRecoveryRecords(nextRecords);
|
||
appState.recoveryRecords = getRecoveryRecords();
|
||
return normalized;
|
||
}
|
||
|
||
function clearRecoveryRecords() {
|
||
appState.recoveryRecords = [];
|
||
}
|
||
|
||
function isQuotaRelatedMessage(message) {
|
||
const text = String(message || "");
|
||
return /配额|额度|预算|storage_over_limit|quota/i.test(text);
|
||
}
|
||
|
||
function describeActionError(error, fallbackTitle = "执行失败") {
|
||
const raw = String(error?.message || error || "").trim();
|
||
const status = Number(error?.status || 0);
|
||
const result = {
|
||
title: fallbackTitle,
|
||
summary: raw || "请稍后重试",
|
||
tone: status === 403 ? "orange" : "red",
|
||
hint: ""
|
||
};
|
||
if (!raw) return result;
|
||
|
||
const quotaPatterns = [
|
||
{
|
||
pattern: /analysis 配额已用完/i,
|
||
title: "分析额度已用完",
|
||
summary: "当前租户本周期的分析额度已经用完,暂时不能继续发起分析类任务。",
|
||
hint: "可以先补额度,或者等待下个周期再恢复。"
|
||
},
|
||
{
|
||
pattern: /copy 配额已用完/i,
|
||
title: "文案额度已用完",
|
||
summary: "当前租户本周期的文案额度已经用完,暂时不能继续生成文案。",
|
||
hint: "可以先补额度,或者改用已有产物继续复盘。"
|
||
},
|
||
{
|
||
pattern: /ai_video 配额已用完/i,
|
||
title: "AI 视频额度已用完",
|
||
summary: "当前租户本周期的 AI 视频额度已经用完,暂时不能继续发起 AI 视频任务。",
|
||
hint: "可以先补额度,再恢复这条任务。"
|
||
},
|
||
{
|
||
pattern: /real_cut 配额已用完/i,
|
||
title: "实拍剪辑额度已用完",
|
||
summary: "当前租户本周期的实拍剪辑额度已经用完,暂时不能继续发起剪辑任务。",
|
||
hint: "可以先补额度,或者改为只做分析类恢复。"
|
||
},
|
||
{
|
||
pattern: /recorder 配额已用完/i,
|
||
title: "录制额度已用完",
|
||
summary: "当前租户本周期的录制额度已经用完,暂时不能新增直播录制源。",
|
||
hint: "可以先补额度,再继续新增录制源。"
|
||
},
|
||
{
|
||
pattern: /预算不足|monthly budget/i,
|
||
title: "本周期预算不足",
|
||
summary: "当前租户本周期预算已经不够继续执行这项动作。",
|
||
hint: "可以先扩容预算,再重新发起恢复。"
|
||
},
|
||
{
|
||
pattern: /存储额度已满|storage_over_limit/i,
|
||
title: "存储额度已满",
|
||
summary: "当前租户存储额度已触顶,系统已经停止继续产生大文件缓存。",
|
||
hint: "建议先清理旧产物或提升存储上限。"
|
||
}
|
||
];
|
||
for (const item of quotaPatterns) {
|
||
if (item.pattern.test(raw)) {
|
||
return {
|
||
...result,
|
||
title: item.title,
|
||
summary: item.summary,
|
||
hint: item.hint,
|
||
tone: "orange"
|
||
};
|
||
}
|
||
}
|
||
|
||
if (status === 403 && /permission|not allowed|required|admin/i.test(raw)) {
|
||
return {
|
||
...result,
|
||
title: "权限不足",
|
||
summary: "当前账号没有执行这项动作的权限。",
|
||
hint: "请确认账号审批状态,或者切换到超级管理员账号。",
|
||
tone: "orange"
|
||
};
|
||
}
|
||
if (status === 409) {
|
||
return {
|
||
...result,
|
||
title: "任务状态冲突",
|
||
summary: raw,
|
||
hint: "请刷新后重新查看当前任务状态。",
|
||
tone: "orange"
|
||
};
|
||
}
|
||
if (status === 404) {
|
||
return {
|
||
...result,
|
||
title: "资源不存在",
|
||
summary: raw,
|
||
hint: "请刷新页面后再试一次。",
|
||
tone: "orange"
|
||
};
|
||
}
|
||
if (isQuotaRelatedMessage(raw)) {
|
||
return {
|
||
...result,
|
||
title: "额度拦截",
|
||
summary: raw,
|
||
hint: "系统已经阻止继续执行,先补额度或清理存储再恢复。",
|
||
tone: "orange"
|
||
};
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function formatActionErrorMessage(error, fallbackTitle = "执行失败") {
|
||
const detail = describeActionError(error, fallbackTitle);
|
||
return [detail.title, detail.summary, detail.hint].filter(Boolean).join(" · ");
|
||
}
|
||
|
||
function isMissingBackendCapability(error) {
|
||
const status = Number(error?.status || error?.payload?.status_code || 0);
|
||
if (status === 404) return true;
|
||
const raw = String(error?.message || error?.payload?.detail || error?.statusText || "").trim().toLowerCase();
|
||
return raw.includes("not found") || raw.includes("404");
|
||
}
|
||
|
||
function presentActionFailure(error, fallbackTitle = "执行失败") {
|
||
const detail = describeActionError(error, fallbackTitle);
|
||
rememberAction(detail.title, [detail.summary, detail.hint].filter(Boolean).join(" · "), detail.tone);
|
||
renderAll();
|
||
return detail;
|
||
}
|
||
|
||
function loadStoredSession() {
|
||
return SESSION_STORE.loadStoredSession();
|
||
}
|
||
|
||
function persistSession(session) {
|
||
appState.session = session;
|
||
SESSION_STORE.persistSession(session);
|
||
}
|
||
|
||
function setLastSeenAt(value) {
|
||
appState.lastSeenAt = SESSION_STORE.setLastSeenAt(value);
|
||
}
|
||
|
||
function normalizeBackendUrlValue(value) {
|
||
return String(value || "").trim().replace(/\/$/, "");
|
||
}
|
||
|
||
function hasSessionBackendMismatch(expectedBackendUrl = DEFAULT_BACKEND_URL) {
|
||
if (!appState.session) return false;
|
||
const expected = normalizeBackendUrlValue(expectedBackendUrl);
|
||
const current = normalizeBackendUrlValue(appState.session.backendUrl);
|
||
return Boolean(expected && current && expected !== current);
|
||
}
|
||
|
||
function isMobileViewport() {
|
||
return typeof window !== "undefined" && Boolean(window.matchMedia?.("(max-width: 760px)")?.matches);
|
||
}
|
||
|
||
function formatBackendDisplayLabel(value = DEFAULT_BACKEND_URL) {
|
||
const normalized = normalizeBackendUrlValue(value || DEFAULT_BACKEND_URL);
|
||
if (!normalized) return "未配置后端";
|
||
try {
|
||
const parsed = new URL(normalized);
|
||
return parsed.host || normalized;
|
||
} catch {
|
||
return normalized;
|
||
}
|
||
}
|
||
|
||
function compareDateDesc(leftValue, rightValue) {
|
||
return new Date(rightValue || 0).getTime() - new Date(leftValue || 0).getTime();
|
||
}
|
||
|
||
function buildAssistantNameMap(items) {
|
||
return new Map(
|
||
safeArray(items)
|
||
.filter((item) => item?.id)
|
||
.map((item) => [item.id, item.name || ""])
|
||
);
|
||
}
|
||
|
||
function getTrackingCursorForPlatform(platform = getCurrentPlatformValue()) {
|
||
const normalizedPlatform = normalizePlatformValue(platform, "");
|
||
return appState.trackingCursorMap?.[normalizedPlatform] || "";
|
||
}
|
||
|
||
function getTrackingSinceIso(platform = getCurrentPlatformValue()) {
|
||
const cursor = getTrackingCursorForPlatform(platform) || appState.lastSeenAt || Date.now();
|
||
const date = new Date(cursor);
|
||
if (Number.isNaN(date.getTime())) return new Date(Date.now() - 86400000).toISOString();
|
||
return date.toISOString();
|
||
}
|
||
|
||
function enrichTrackingAccounts(items, accountIndex, assistantNameMap, fallbackPlatform) {
|
||
return safeArray(items)
|
||
.map((item) => {
|
||
const account = accountIndex.get(item.tracked_account_id) || item.account || null;
|
||
const normalizedPlatform = normalizePlatformValue(
|
||
item.platform || account?.platform || fallbackPlatform || "",
|
||
fallbackPlatform || "douyin"
|
||
);
|
||
return {
|
||
...item,
|
||
platform: normalizedPlatform,
|
||
assistant_name: item.assistant_name || assistantNameMap.get(item.assistant_id) || "",
|
||
account
|
||
};
|
||
})
|
||
.sort((left, right) => compareDateDesc(left.updated_at || left.created_at, right.updated_at || right.created_at));
|
||
}
|
||
|
||
function enrichTrackingDigestItems(items, accountIndex, fallbackPlatform) {
|
||
return safeArray(items)
|
||
.map((item) => {
|
||
const account = item.account || accountIndex.get(item.tracked_account_id) || null;
|
||
const platform = normalizePlatformValue(
|
||
item.platform || item.video?.platform || account?.platform || fallbackPlatform || "",
|
||
fallbackPlatform || "douyin"
|
||
);
|
||
const performanceScore = Number(item.video?.score?.performance_score || 0);
|
||
return {
|
||
...item,
|
||
platform,
|
||
account,
|
||
summary: item.summary || item.summary_text || item.video?.description || item.video?.title || "已发现更新内容",
|
||
is_high_value: typeof item.is_high_value === "boolean" ? item.is_high_value : performanceScore >= 60
|
||
};
|
||
})
|
||
.sort((left, right) => compareDateDesc(
|
||
left.video?.published_at || left.created_at,
|
||
right.video?.published_at || right.created_at
|
||
));
|
||
}
|
||
|
||
function markSeenNow() {
|
||
setLastSeenAt(Date.now());
|
||
}
|
||
|
||
function setBusy(next, message = "") {
|
||
appState.busy = next;
|
||
appState.message = message;
|
||
renderAuthUi();
|
||
}
|
||
|
||
function getScreenLabel(screenId = appState.screen) {
|
||
return SCREEN_LABELS[screenId] || "StoryForge";
|
||
}
|
||
|
||
function getMobileProjectLabel() {
|
||
const selectedProject = typeof getSelectedProject === "function" ? getSelectedProject() : null;
|
||
if (selectedProject?.name) return selectedProject.name;
|
||
if (appState.selectedWorkspace?.project?.name) return appState.selectedWorkspace.project.name;
|
||
if (appState.selectedWorkspace?.account?.display_name) return appState.selectedWorkspace.account.display_name;
|
||
return appState.me?.display_name || appState.me?.username || "当前工作区";
|
||
}
|
||
|
||
function setMobileSidebarOpen(next) {
|
||
document.body.classList.toggle("mobile-sidebar-open", Boolean(next));
|
||
}
|
||
|
||
function syncMobileShell() {
|
||
if (mobileScreenTitle) mobileScreenTitle.textContent = getScreenLabel();
|
||
if (mobileProjectTitle) mobileProjectTitle.textContent = getMobileProjectLabel();
|
||
if (mobileShellStatus) {
|
||
mobileShellStatus.textContent = appState.busy ? "同步中" : (appState.session ? "已连接" : "连接状态");
|
||
}
|
||
}
|
||
|
||
function getScreenFromHash() {
|
||
const next = window.location.hash.replace("#", "");
|
||
return screenMap[next] ? next : "dashboard";
|
||
}
|
||
|
||
function getMobileTabGroup(screenId = appState.screen) {
|
||
const groups = {
|
||
dashboard: "dashboard",
|
||
credits: "dashboard",
|
||
settings: "dashboard",
|
||
intake: "intake",
|
||
owned: "intake",
|
||
discovery: "discovery",
|
||
tracking: "discovery",
|
||
production: "production",
|
||
review: "production",
|
||
automation: "production",
|
||
playbook: "playbook",
|
||
strategy: "playbook",
|
||
"admin-workbench": "playbook"
|
||
};
|
||
return groups[screenId] || screenId || "dashboard";
|
||
}
|
||
|
||
function setScreen(id, options = {}) {
|
||
const { updateHash = true } = options;
|
||
const resolvedId = screenMap[id] ? id : "dashboard";
|
||
const mobileGroup = getMobileTabGroup(resolvedId);
|
||
setMobileSidebarOpen(false);
|
||
appState.screen = resolvedId;
|
||
navButtons.forEach((button) => {
|
||
const active = button.dataset.screenTarget === resolvedId;
|
||
const mobileGroupActive = button.classList.contains("mobile-tabbar-item") && button.dataset.screenTarget === mobileGroup;
|
||
button.classList.toggle("is-active", active || mobileGroupActive);
|
||
});
|
||
screens.forEach((screen) => {
|
||
screen.classList.toggle("is-active", screen.dataset.screen === resolvedId);
|
||
});
|
||
if (updateHash && window.location.hash !== `#${resolvedId}`) {
|
||
window.location.hash = resolvedId;
|
||
}
|
||
syncMobileShell();
|
||
}
|
||
|
||
function ensureAuthUi() {
|
||
const topbarRight = document.querySelector(".topbar-right");
|
||
if (!topbarRight) return;
|
||
|
||
if (!document.querySelector(".auth-inline")) {
|
||
const inline = document.createElement("div");
|
||
inline.className = "auth-inline";
|
||
inline.innerHTML = `
|
||
<button class="btn btn-secondary" type="button" data-action="open-auth">连接状态</button>
|
||
<button class="btn btn-secondary" type="button" data-action="logout-session">退出</button>
|
||
<span class="auth-status"></span>
|
||
`;
|
||
topbarRight.insertBefore(inline, topbarRight.firstChild);
|
||
}
|
||
|
||
if (!document.querySelector(".auth-modal")) {
|
||
const modal = document.createElement("div");
|
||
modal.className = "auth-modal-backdrop hidden";
|
||
modal.innerHTML = `
|
||
<div class="auth-modal">
|
||
<form class="auth-form" data-role="auth-form">
|
||
<div class="auth-head">
|
||
<div>
|
||
<h3>连接 StoryForge</h3>
|
||
<p>当前站点会直接向后端请求自动会话,不再要求用户输入账号密码。</p>
|
||
</div>
|
||
<button class="btn btn-secondary" type="button" data-action="close-auth">关闭</button>
|
||
</div>
|
||
<div class="field-stack">
|
||
<label>后端地址</label>
|
||
<input type="text" data-auth-field="backendUrl" placeholder="${DEFAULT_BACKEND_URL}" autocomplete="url" readonly />
|
||
</div>
|
||
<div class="task-item compact">
|
||
<h4>自动连接说明</h4>
|
||
<p>前端只会请求固定后端的自动会话接口;如果当前部署没有开启自动建会话,这里会直接显示服务端返回的原因。</p>
|
||
</div>
|
||
<div class="helper-text" data-role="auth-message"></div>
|
||
<div class="auth-actions">
|
||
<button class="btn btn-secondary" type="button" data-action="logout-session">退出工作区</button>
|
||
<button class="btn btn-secondary" type="button" data-action="auth-refresh">重新加载</button>
|
||
<button class="btn btn-primary" type="submit" data-action="submit-auth">自动连接</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
}
|
||
}
|
||
|
||
function renderAuthUi() {
|
||
ensureAuthUi();
|
||
const session = appState.session;
|
||
const openButton = document.querySelector('[data-action="open-auth"]');
|
||
const logoutButtons = document.querySelectorAll('[data-action="logout-session"]');
|
||
const status = document.querySelector(".auth-status");
|
||
const message = document.querySelector('[data-role="auth-message"]');
|
||
if (openButton) openButton.textContent = session ? "连接状态" : "自动连接";
|
||
logoutButtons.forEach((button) => {
|
||
button.hidden = !session;
|
||
});
|
||
if (status) {
|
||
status.textContent = appState.busy
|
||
? appState.message || "正在加载..."
|
||
: session
|
||
? `${session.account?.display_name || session.account?.username || "已连接"} · ${session.backendUrl}`
|
||
: appState.autoConnectError || "等待自动连接";
|
||
}
|
||
if (message) {
|
||
message.textContent = appState.busy ? appState.message : (appState.autoConnectError || "");
|
||
}
|
||
syncMobileShell();
|
||
}
|
||
|
||
function openAuthModal() {
|
||
ensureAuthUi();
|
||
const modal = document.querySelector(".auth-modal-backdrop");
|
||
if (!modal) return;
|
||
const session = appState.session;
|
||
document.body.classList.add("sheet-open", "auth-sheet-open");
|
||
modal.classList.remove("hidden");
|
||
setAuthField("backendUrl", session?.backendUrl || DEFAULT_BACKEND_URL);
|
||
}
|
||
|
||
function closeAuthModal() {
|
||
document.body.classList.remove("sheet-open", "auth-sheet-open");
|
||
document.querySelector(".auth-modal-backdrop")?.classList.add("hidden");
|
||
}
|
||
|
||
function setAuthField(name, value) {
|
||
const input = document.querySelector(`[data-auth-field="${name}"]`);
|
||
if (input) input.value = value ?? "";
|
||
}
|
||
|
||
function readAuthForm() {
|
||
return {
|
||
backendUrl: document.querySelector('[data-auth-field="backendUrl"]')?.value?.trim() || DEFAULT_BACKEND_URL
|
||
};
|
||
}
|
||
|
||
let currentActionConfig = null;
|
||
|
||
function ensureActionUi() {
|
||
if (document.querySelector(".action-modal-backdrop")) return;
|
||
const modal = document.createElement("div");
|
||
modal.className = "action-modal-backdrop hidden";
|
||
modal.innerHTML = `
|
||
<div class="action-modal">
|
||
<div class="sheet-handle" aria-hidden="true"></div>
|
||
<div class="auth-head">
|
||
<div>
|
||
<h3 data-role="action-title">快速操作</h3>
|
||
<p data-role="action-description">根据当前工作区执行动作。</p>
|
||
</div>
|
||
<button class="btn btn-secondary" type="button" data-action="close-sheet">关闭</button>
|
||
</div>
|
||
<div class="field-stack" data-role="action-fields"></div>
|
||
<div class="helper-text" data-role="action-message"></div>
|
||
<div class="auth-actions">
|
||
<button class="btn btn-secondary" type="button" data-action="close-sheet">取消</button>
|
||
<button class="btn btn-primary" type="button" data-action="submit-sheet">执行</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
}
|
||
|
||
function renderActionFields(fields) {
|
||
return fields.map((field) => {
|
||
if (field.hidden) {
|
||
return "";
|
||
}
|
||
const common = `data-action-field="${escapeHtml(field.name)}"`;
|
||
if (field.type === "html") {
|
||
return `
|
||
<div class="field-stack">
|
||
<label>${escapeHtml(field.label || "")}</label>
|
||
<div class="sheet-html">${field.html || ""}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
if (field.type === "textarea") {
|
||
return `
|
||
<div class="field-stack">
|
||
<label>${escapeHtml(field.label)}</label>
|
||
<textarea ${common} rows="${escapeHtml(field.rows || 4)}" placeholder="${escapeHtml(field.placeholder || "")}">${escapeHtml(field.value || "")}</textarea>
|
||
</div>
|
||
`;
|
||
}
|
||
if (field.type === "select") {
|
||
return `
|
||
<div class="field-stack">
|
||
<label>${escapeHtml(field.label)}</label>
|
||
<select ${common}>
|
||
${(field.options || []).map((option) => `
|
||
<option value="${escapeHtml(option.value)}" ${String(option.value) === String(field.value ?? "") ? "selected" : ""}>${escapeHtml(option.label)}</option>
|
||
`).join("")}
|
||
</select>
|
||
</div>
|
||
`;
|
||
}
|
||
if (field.type === "checkbox") {
|
||
return `
|
||
<label class="checkbox-row">
|
||
<input type="checkbox" ${common} ${field.value ? "checked" : ""} />
|
||
<span>${escapeHtml(field.label)}</span>
|
||
</label>
|
||
`;
|
||
}
|
||
if (field.type === "file") {
|
||
return `
|
||
<div class="field-stack">
|
||
<label>${escapeHtml(field.label)}</label>
|
||
<input type="file" ${common} accept="${escapeHtml(field.accept || "")}" />
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div class="field-stack">
|
||
<label>${escapeHtml(field.label)}</label>
|
||
<input
|
||
type="${escapeHtml(field.type || "text")}"
|
||
${common}
|
||
value="${escapeHtml(field.value || "")}"
|
||
placeholder="${escapeHtml(field.placeholder || "")}"
|
||
${field.min != null ? `min="${escapeHtml(field.min)}"` : ""}
|
||
${field.max != null ? `max="${escapeHtml(field.max)}"` : ""}
|
||
/>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
function openActionModal(config) {
|
||
ensureActionUi();
|
||
currentActionConfig = config;
|
||
const modal = document.querySelector(".action-modal-backdrop");
|
||
const title = document.querySelector('[data-role="action-title"]');
|
||
const description = document.querySelector('[data-role="action-description"]');
|
||
const fields = document.querySelector('[data-role="action-fields"]');
|
||
const message = document.querySelector('[data-role="action-message"]');
|
||
const submit = document.querySelector('[data-action="submit-sheet"]');
|
||
if (!modal || !title || !description || !fields || !message || !submit) return;
|
||
title.textContent = config.title || "快速操作";
|
||
description.textContent = config.description || "";
|
||
fields.innerHTML = renderActionFields(config.fields || []);
|
||
message.textContent = "";
|
||
submit.textContent = config.submitLabel || "执行";
|
||
submit.disabled = false;
|
||
submit.hidden = Boolean(config.hideSubmit);
|
||
document.body.classList.add("sheet-open", "action-sheet-open");
|
||
modal.classList.remove("hidden");
|
||
if (typeof config.onOpen === "function") {
|
||
config.onOpen({ modal, title, description, fields, message, submit });
|
||
}
|
||
}
|
||
|
||
function closeActionModal() {
|
||
currentActionConfig = null;
|
||
document.body.classList.remove("sheet-open", "action-sheet-open");
|
||
document.querySelector(".action-modal-backdrop")?.classList.add("hidden");
|
||
}
|
||
|
||
function ensureOneLinerUi() {
|
||
if (!document.querySelector(".oneliner-fab")) {
|
||
const fab = document.createElement("button");
|
||
fab.className = "oneliner-fab";
|
||
fab.type = "button";
|
||
fab.dataset.action = "open-oneliner";
|
||
fab.innerHTML = `
|
||
<span class="oneliner-fab-mark">1</span>
|
||
<span class="oneliner-fab-text">OneLiner</span>
|
||
`;
|
||
document.body.appendChild(fab);
|
||
}
|
||
if (!document.querySelector(".oneliner-backdrop")) {
|
||
const panel = document.createElement("div");
|
||
panel.className = "oneliner-backdrop hidden";
|
||
panel.innerHTML = `
|
||
<div class="oneliner-panel">
|
||
<div class="sheet-handle" aria-hidden="true"></div>
|
||
<div class="oneliner-head">
|
||
<div>
|
||
<h3>OneLiner</h3>
|
||
<p>前端没上的需求,先由总控主 Agent 承接。</p>
|
||
</div>
|
||
<div class="task-meta">
|
||
<button class="btn btn-secondary" type="button" data-action="open-oneliner-profile">配置</button>
|
||
<button class="btn btn-secondary" type="button" data-action="close-oneliner">关闭</button>
|
||
</div>
|
||
</div>
|
||
<div class="oneliner-meta" data-role="oneliner-meta"></div>
|
||
<div class="oneliner-runs" data-role="oneliner-runs"></div>
|
||
<div class="oneliner-sessions" data-role="oneliner-sessions"></div>
|
||
<div class="oneliner-messages" data-role="oneliner-messages"></div>
|
||
<form class="oneliner-composer" data-role="oneliner-form">
|
||
<textarea data-role="oneliner-input" rows="4" placeholder="比如:帮我把这个账号加入跟踪并绑定快手 Agent;或者这个需求前端还没有,你直接帮我拆任务。"></textarea>
|
||
<div class="oneliner-actions">
|
||
<div class="helper-text" data-role="oneliner-status"></div>
|
||
<button class="btn btn-primary" type="submit" data-action="submit-oneliner">发送给 OneLiner</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(panel);
|
||
}
|
||
}
|
||
|
||
function renderOneLinerSessionTabs() {
|
||
const sessions = safeArray(appState.onelinerSessions).slice(0, 6);
|
||
if (!sessions.length) {
|
||
return `<div class="task-meta"><span class="tag blue">还没有会话</span><span class="tag">发送第一条需求后自动创建</span></div>`;
|
||
}
|
||
const currentId = getCurrentOneLinerSession()?.id || "";
|
||
return `
|
||
<div class="chip-row">
|
||
${sessions.map((session) => `
|
||
<span class="chip clickable-tag ${session.id === currentId ? "active" : ""}" data-action="select-oneliner-session" data-session-id="${escapeHtml(session.id)}">
|
||
${escapeHtml(brief(session.title || "新会话", 14))}
|
||
</span>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderOneLinerRunsHtml() {
|
||
const runs = safeArray(appState.onelinerRuns);
|
||
const currentRun = getCurrentOneLinerRun();
|
||
if (!runs.length || !currentRun) {
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>还没有主 Agent 运行任务</h4>
|
||
<p>你在首页、策略页或 Agent 页点击“交给主 Agent”后,这里会先出现待确认执行卡。</p>
|
||
</div>
|
||
`;
|
||
}
|
||
const runEvents = safeArray(currentRun.events).slice(-3);
|
||
const planSteps = safeArray(currentRun.plan?.steps).slice(0, 4);
|
||
const resultPayload = currentRun.result && typeof currentRun.result === "object" ? currentRun.result : null;
|
||
const previewAction = currentRun.recommended_preview_action || null;
|
||
const recommendedAction = resultPayload?.recommended_action || null;
|
||
const pendingRunCount = safeArray(runs).filter((item) => item.run_status === "needs_confirmation").length;
|
||
const activeRunCount = safeArray(runs).filter((item) => ["queued", "running", "blocked"].includes(item.run_status)).length;
|
||
const completedRunCount = safeArray(runs).filter((item) => item.run_status === "done").length;
|
||
const problemRunCount = safeArray(runs).filter((item) => ["blocked", "failed", "cancelled"].includes(item.run_status)).length;
|
||
const filterKey = String(appState.onelinerRunFilter || "").trim() || (activeRunCount || pendingRunCount ? "focus" : completedRunCount ? "done" : "problems");
|
||
const runFilterPredicates = {
|
||
focus: (item) => ["needs_confirmation", "queued", "running", "blocked"].includes(item.run_status),
|
||
done: (item) => item.run_status === "done",
|
||
problems: (item) => ["blocked", "failed", "cancelled"].includes(item.run_status),
|
||
all: () => true
|
||
};
|
||
const filteredRuns = safeArray(runs).filter(runFilterPredicates[filterKey] || runFilterPredicates.focus);
|
||
const visibleRuns = (filteredRuns.length ? filteredRuns : runs).slice(0, 8);
|
||
const recentCompletedRuns = safeArray(runs)
|
||
.filter((item) => item.run_status === "done" && item.id !== currentRun.id)
|
||
.slice(0, 3);
|
||
const previewLandingAttrs = buildMainAgentLandingAttrs({
|
||
runId: currentRun.id || "",
|
||
screen: previewAction?.screen || "",
|
||
title: currentRun.title || currentRun.plan?.goal || "主 Agent 任务",
|
||
summary: previewAction?.summary || currentRun.summary || ""
|
||
});
|
||
const resultLandingAttrs = buildMainAgentLandingAttrs({
|
||
runId: currentRun.id || "",
|
||
screen: recommendedAction?.screen || "",
|
||
title: currentRun.title || currentRun.plan?.goal || "主 Agent 任务",
|
||
summary: recommendedAction?.summary || currentRun.status_summary || ""
|
||
});
|
||
const hasResultPayload = Boolean(resultPayload && Object.keys(resultPayload).length);
|
||
const runStatusLabel = {
|
||
needs_confirmation: "待确认",
|
||
queued: "排队中",
|
||
running: "执行中",
|
||
blocked: "已阻塞",
|
||
done: "已完成",
|
||
failed: "已失败",
|
||
cancelled: "已取消"
|
||
}[currentRun.run_status] || currentRun.run_status || "运行中";
|
||
const statusTone = currentRun.run_status === "needs_confirmation"
|
||
? "blue"
|
||
: currentRun.run_status === "running"
|
||
? "green"
|
||
: currentRun.run_status === "queued"
|
||
? "orange"
|
||
: currentRun.run_status === "failed"
|
||
? "orange"
|
||
: "";
|
||
const canRetryCurrentRun = ["blocked", "failed", "cancelled"].includes(currentRun.run_status);
|
||
const currentRunConfigVersion = currentRun.governance?.oneliner_profile_version || currentRun.governance?.oneliner_profile?.current_version || {};
|
||
const currentRunPlatformAgentProfile = currentRun.result?.execution_card?.platform_agent_profile || currentRun.governance?.platform_agent_profile || {};
|
||
return `
|
||
<div class="task-item compact" style="margin-bottom:10px;">
|
||
<h4>近期运行概况</h4>
|
||
<p>先看待确认和执行中的任务,再切回当前 run 继续推进。</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">待确认 ${escapeHtml(formatNumber(pendingRunCount))}</span>
|
||
<span class="tag green">执行中 ${escapeHtml(formatNumber(activeRunCount))}</span>
|
||
<span class="tag">已完成 ${escapeHtml(formatNumber(completedRunCount))}</span>
|
||
<span class="tag orange">异常 ${escapeHtml(formatNumber(problemRunCount))}</span>
|
||
</div>
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
<span class="tag clickable-tag ${filterKey === "focus" ? "blue" : ""}" data-action="select-oneliner-run-filter" data-run-filter="focus">重点运行</span>
|
||
<span class="tag clickable-tag ${filterKey === "done" ? "green" : ""}" data-action="select-oneliner-run-filter" data-run-filter="done">已完成</span>
|
||
<span class="tag clickable-tag ${filterKey === "problems" ? "orange" : ""}" data-action="select-oneliner-run-filter" data-run-filter="problems">异常运行</span>
|
||
<span class="tag clickable-tag ${filterKey === "all" ? "orange" : ""}" data-action="select-oneliner-run-filter" data-run-filter="all">全部</span>
|
||
</div>
|
||
${runs.length > 1 ? `
|
||
<div class="chip-row" style="margin-top:10px;">
|
||
${visibleRuns.map((item) => `
|
||
<span class="chip clickable-tag ${item.id === currentRun.id ? "active" : ""}" data-action="select-oneliner-run" data-run-id="${escapeHtml(item.id)}">
|
||
${escapeHtml(brief(item.title || item.plan?.goal || "主 Agent 任务", 14))} · ${escapeHtml({
|
||
needs_confirmation: "待确认",
|
||
queued: "排队中",
|
||
running: "执行中",
|
||
blocked: "阻塞",
|
||
done: "已完成",
|
||
failed: "失败",
|
||
cancelled: "已取消"
|
||
}[item.run_status] || item.run_status || "运行中")}
|
||
</span>
|
||
`).join("")}
|
||
</div>
|
||
${!filteredRuns.length ? `
|
||
<div class="panel-subtitle" style="margin-top:8px;">当前筛选下还没有对应运行,已临时显示全部任务。</div>
|
||
` : ""}
|
||
` : ""}
|
||
</div>
|
||
<div class="task-item compact oneliner-run-card">
|
||
<div class="mobile-only compact-summary-row">
|
||
<span>当前运行</span>
|
||
<strong>${escapeHtml(runStatusLabel)}</strong>
|
||
<span>${escapeHtml(currentRun.platform_scope === "all_platforms" ? "全平台" : "单平台")}</span>
|
||
${currentRunConfigVersion.version_no ? `<span>${escapeHtml(`配置 v${formatNumber(currentRunConfigVersion.version_no || 0)}`)}</span>` : ""}
|
||
<strong>${escapeHtml(recommendedAction?.label || "继续这个任务")}</strong>
|
||
</div>
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前运行</strong>
|
||
<span class="tag ${statusTone}">${escapeHtml(runStatusLabel)}</span>
|
||
</div>
|
||
<p>${escapeHtml(currentRun.summary || currentRun.status_summary || "主 Agent 会先给你一张确认卡,再继续执行。")}</p>
|
||
<div class="task-meta">
|
||
${currentRun.run_status === "needs_confirmation" ? `
|
||
<span class="tag clickable-tag" data-action="confirm-oneliner-run" data-run-id="${escapeHtml(currentRun.id)}">确认执行</span>
|
||
<span class="tag clickable-tag" data-action="cancel-oneliner-run" data-run-id="${escapeHtml(currentRun.id)}">取消本轮</span>
|
||
` : ""}
|
||
${canRetryCurrentRun ? `<span class="tag clickable-tag" data-action="retry-oneliner-run" data-run-id="${escapeHtml(currentRun.id)}">重新执行</span>` : ""}
|
||
${hasResultPayload ? `<span class="tag clickable-tag" data-action="open-oneliner-run-result" data-run-id="${escapeHtml(currentRun.id)}">查看结果</span>` : ""}
|
||
${recommendedAction?.action ? `<span class="tag clickable-tag" data-action="${escapeHtml(recommendedAction.action)}" ${resultLandingAttrs}>${escapeHtml(recommendedAction.label || "继续这个任务")}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="panel-head">
|
||
<div>
|
||
<h4>${escapeHtml(currentRun.title || currentRun.plan?.goal || "主 Agent 任务")}</h4>
|
||
<div class="panel-subtitle">${escapeHtml(currentRun.summary || currentRun.status_summary || "主 Agent 会先给你一张确认卡,再继续执行。")}</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag ${statusTone}">${escapeHtml(runStatusLabel)}</span>
|
||
${currentRun.platform_label ? `<span class="tag">${escapeHtml(currentRun.platform_label)}</span>` : ""}
|
||
<span class="tag">${escapeHtml(onelinerIntentLabel(currentRun.intent_key))}</span>
|
||
${currentRunConfigVersion.version_no ? `<span class="tag blue">配置 v${escapeHtml(formatNumber(currentRunConfigVersion.version_no || 0))}</span>` : ""}
|
||
${currentRun.source_screen ? `<span class="tag">${escapeHtml(currentRun.source_screen)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
${currentRun.active_admin_override_notice?.title ? `
|
||
<div class="task-item compact" style="margin-top:10px; border-color:rgba(245, 158, 11, 0.28); background:linear-gradient(180deg, rgba(255, 250, 240, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);">
|
||
<h4>管理员覆盖生效中</h4>
|
||
<p>${escapeHtml(currentRun.active_admin_override_notice.summary || "当前运行会优先遵循管理员覆盖层。")}</p>
|
||
</div>
|
||
` : ""}
|
||
<div class="task-item compact" style="margin-top:10px;">
|
||
<h4>当前计划</h4>
|
||
<p>${escapeHtml(currentRun.plan?.summary || currentRun.summary || "主 Agent 会先按这张确认卡理解目标,再继续执行。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(currentRun.source_action_key || "manual-handoff")}</span>
|
||
<span class="tag">${escapeHtml(currentRun.platform_scope === "all_platforms" ? "全平台" : "单平台")}</span>
|
||
${currentRun.delivery_mode ? `<span class="tag">${escapeHtml(currentRun.delivery_mode)}</span>` : ""}
|
||
${currentRunConfigVersion.version_no ? `<span class="tag">配置版本 ${escapeHtml(formatNumber(currentRunConfigVersion.version_no || 0))}</span>` : ""}
|
||
${previewAction?.action ? `<span class="tag clickable-tag" data-action="${escapeHtml(previewAction.action)}" ${previewLandingAttrs}>${escapeHtml(`预计落点 · ${previewAction.label || "对应页面"}`)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
${currentRunConfigVersion.version_no ? `
|
||
<div class="task-item compact" style="margin-top:10px;">
|
||
<h4>本轮主配置版本</h4>
|
||
<p>${escapeHtml(currentRunConfigVersion.summary || currentRunConfigVersion.title || `OneLiner 配置 v${formatNumber(currentRunConfigVersion.version_no || 0)}`)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">配置 v${escapeHtml(formatNumber(currentRunConfigVersion.version_no || 0))}</span>
|
||
<span class="tag clickable-tag" data-action="open-oneliner-profile-history">查看配置历史</span>
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${currentRunPlatformAgentProfile.platform ? `
|
||
<div class="task-item compact" style="margin-top:10px;">
|
||
<h4>本轮平台 Agent</h4>
|
||
<p>${escapeHtml(currentRunPlatformAgentProfile.mission || currentRunPlatformAgentProfile.name || `${currentRunPlatformAgentProfile.platform_label || platformLabel(currentRunPlatformAgentProfile.platform)} Agent 正在承接这轮执行。`)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(currentRunPlatformAgentProfile.platform_label || platformLabel(currentRunPlatformAgentProfile.platform))}</span>
|
||
${currentRunPlatformAgentProfile.name ? `<span class="tag">${escapeHtml(currentRunPlatformAgentProfile.name)}</span>` : ""}
|
||
${currentRunPlatformAgentProfile.assistant_name ? `<span class="tag green">${escapeHtml(currentRunPlatformAgentProfile.assistant_name)}</span>` : ""}
|
||
${currentRunPlatformAgentProfile.readiness_label ? `<span class="tag ${currentRunPlatformAgentProfile.readiness_score >= 75 ? "green" : currentRunPlatformAgentProfile.readiness_score >= 50 ? "blue" : "orange"}">${escapeHtml(currentRunPlatformAgentProfile.readiness_label)} ${escapeHtml(formatNumber(currentRunPlatformAgentProfile.readiness_score || 0))}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${!hasResultPayload && previewAction?.summary ? `<div class="panel-subtitle" style="margin-top:8px;">${escapeHtml(previewAction.summary)}</div>` : ""}
|
||
${planSteps.length ? `
|
||
<div class="list" style="margin-top:10px;">
|
||
${planSteps.map((step, index) => `
|
||
<div class="task-item compact">
|
||
<h4>步骤 ${escapeHtml(formatNumber(index + 1))}</h4>
|
||
<p>${escapeHtml(step)}</p>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
${currentRun.run_status === "needs_confirmation" ? `
|
||
<span class="tag clickable-tag" data-action="confirm-oneliner-run" data-run-id="${escapeHtml(currentRun.id)}">确认执行</span>
|
||
<span class="tag clickable-tag" data-action="cancel-oneliner-run" data-run-id="${escapeHtml(currentRun.id)}">取消本轮</span>
|
||
` : `
|
||
<span class="tag ${statusTone}">${escapeHtml(currentRun.status_summary || "主 Agent 正在推进中")}</span>
|
||
`}
|
||
${canRetryCurrentRun ? `<span class="tag clickable-tag" data-action="retry-oneliner-run" data-run-id="${escapeHtml(currentRun.id)}">重新执行</span>` : ""}
|
||
${hasResultPayload ? `<span class="tag clickable-tag" data-action="open-oneliner-run-result" data-run-id="${escapeHtml(currentRun.id)}">查看结果</span>` : ""}
|
||
${recommendedAction?.action ? `<span class="tag clickable-tag" data-action="${escapeHtml(recommendedAction.action)}" ${resultLandingAttrs}>${escapeHtml(recommendedAction.label || "回到对应页面")}</span>` : ""}
|
||
</div>
|
||
${recommendedAction?.summary ? `<div class="panel-subtitle" style="margin-top:8px;">${escapeHtml(recommendedAction.summary)}</div>` : ""}
|
||
${hasResultPayload ? `
|
||
<div class="task-item compact" style="margin-top:10px;">
|
||
<h4>执行结果</h4>
|
||
<div class="sheet-html">${renderOneLinerExecutionPayloadHtml(currentRun.result)}</div>
|
||
</div>
|
||
` : ""}
|
||
${runEvents.length ? `
|
||
<div class="list" style="margin-top:10px;">
|
||
${runEvents.map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.event_type || "run.progress")}</h4>
|
||
<p>${escapeHtml(item.summary || "运行状态已更新。")}</p>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
${recentCompletedRuns.length ? `
|
||
<div class="task-item compact" style="margin-top:10px;">
|
||
<h4>最近完成</h4>
|
||
<div class="list" style="margin-top:10px;">
|
||
${recentCompletedRuns.map((item) => {
|
||
const doneAction = item.result?.recommended_action || null;
|
||
const doneLandingAttrs = buildMainAgentLandingAttrs({
|
||
runId: item.id || "",
|
||
screen: doneAction?.screen || "",
|
||
title: item.title || item.plan?.goal || "主 Agent 任务",
|
||
summary: doneAction?.summary || item.status_summary || ""
|
||
});
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.title || item.plan?.goal || "主 Agent 任务")}</h4>
|
||
<p>${escapeHtml(item.status_summary || item.summary || "已完成,可继续回看结果。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag green">已完成</span>
|
||
<span class="tag clickable-tag" data-action="open-oneliner-run-result" data-run-id="${escapeHtml(item.id)}">查看结果</span>
|
||
${doneAction?.action ? `<span class="tag clickable-tag" data-action="${escapeHtml(doneAction.action)}" ${doneLandingAttrs}>${escapeHtml(doneAction.label || "回到对应页面")}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderOneLinerMessagesHtml() {
|
||
const messages = safeArray(appState.onelinerMessages);
|
||
if (!messages.length) {
|
||
return `
|
||
<div class="task-item">
|
||
<h4>还没有对话</h4>
|
||
<p>你可以直接说目标,不用先理解平台有什么按钮。OneLiner 会先拆目标,再决定交给哪个平台 Agent。</p>
|
||
</div>
|
||
`;
|
||
}
|
||
return messages.map((message) => {
|
||
const roleClass = message.role === "assistant" ? "assistant" : "user";
|
||
const result = message.result || {};
|
||
const plan = message.plan || {};
|
||
const executionCard = result.execution_card || {};
|
||
const activeAdminOverrideNotice = executionCard.active_admin_override_notice || null;
|
||
const profileVersion = executionCard.oneliner_profile_version || {};
|
||
const actions = safeArray(plan.suggested_actions);
|
||
const secondaryActions = safeArray(executionCard.secondary_actions);
|
||
return `
|
||
<div class="oneliner-message ${roleClass}">
|
||
<div class="oneliner-bubble">
|
||
<strong>${escapeHtml(message.role === "assistant" ? "OneLiner" : "你")}</strong>
|
||
<p>${escapeHtml(message.content || result.summary_text || "")}</p>
|
||
${plan.intent_key ? `
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(onelinerIntentLabel(plan.intent_key))}</span>
|
||
${plan.platform_label ? `<span class="tag">${escapeHtml(plan.platform_label)}</span>` : ""}
|
||
${plan.delivery_mode ? `<span class="tag ${plan.delivery_mode === "oneliner" ? "orange" : "green"}">${escapeHtml(plan.delivery_mode === "oneliner" ? "对话承接" : "可走前端")}</span>` : ""}
|
||
</div>
|
||
` : ""}
|
||
${actions.length ? `
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
${actions.map((item) => `<span class="tag clickable-tag" data-action="${escapeHtml(item.key)}">${escapeHtml(item.label)}</span>`).join("")}
|
||
</div>
|
||
` : ""}
|
||
${message.role === "assistant" && (executionCard.intent_label || executionCard.platform_label || executionCard.primary_action?.label || safeArray(executionCard.evidence).length) ? `
|
||
<div class="task-item compact" style="margin-top:12px;">
|
||
<h4>${escapeHtml(executionCard.intent_label || "本轮执行建议")}</h4>
|
||
<p>${escapeHtml(executionCard.blocked_reason || `${executionCard.platform_label || "待判断平台"} · ${executionCard.delivery_mode === "ui" ? "优先走前端固定动作" : "优先由 OneLiner 对话承接"}`)}</p>
|
||
<div class="task-meta">
|
||
${executionCard.platform_label ? `<span class="tag blue">${escapeHtml(executionCard.platform_label)}</span>` : ""}
|
||
${executionCard.platform_agent_name ? `<span class="tag">${escapeHtml(executionCard.platform_agent_name)}</span>` : ""}
|
||
${executionCard.assistant_name ? `<span class="tag green">${escapeHtml(executionCard.assistant_name)}</span>` : ""}
|
||
${profileVersion.version_no ? `<span class="tag">配置 v${escapeHtml(formatNumber(profileVersion.version_no || 0))}</span>` : ""}
|
||
${profileVersion.version_no ? `<span class="tag clickable-tag" data-action="open-oneliner-profile-history">看主配置历史</span>` : ""}
|
||
${executionCard.platform && executionCard.platform_agent_profile?.version_no ? `<span class="tag clickable-tag" data-action="open-platform-agent-profile-history" data-platform="${escapeHtml(executionCard.platform)}">看平台配置历史</span>` : ""}
|
||
${executionCard.readiness_label ? `<span class="tag ${executionCard.readiness_score >= 75 ? "green" : executionCard.readiness_score >= 50 ? "blue" : "orange"}">${escapeHtml(executionCard.readiness_label)} ${escapeHtml(formatNumber(executionCard.readiness_score || 0))}</span>` : ""}
|
||
${executionCard.primary_action?.key ? `<span class="tag clickable-tag" data-action="${escapeHtml(executionCard.primary_action.key)}">${escapeHtml(executionCard.primary_action.label || "执行下一步")}</span>` : ""}
|
||
</div>
|
||
${profileVersion.version_no ? `
|
||
<div class="panel-subtitle" style="margin-top:8px;">${escapeHtml(profileVersion.summary || profileVersion.title || `当前按 OneLiner 配置 v${formatNumber(profileVersion.version_no || 0)} 执行。`)}</div>
|
||
` : ""}
|
||
${activeAdminOverrideNotice?.title ? `
|
||
<div class="task-item compact" style="margin-top:10px; border-color:rgba(245, 158, 11, 0.28); background:linear-gradient(180deg, rgba(255, 250, 240, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);">
|
||
<h4>管理员覆盖生效中</h4>
|
||
<p>${escapeHtml(activeAdminOverrideNotice.summary || "当前这轮执行会优先遵循管理员覆盖,再叠加你的个人策略。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag orange">${escapeHtml(activeAdminOverrideNotice.title || "管理员覆盖")}</span>
|
||
${activeAdminOverrideNotice.platform_label ? `<span class="tag">${escapeHtml(activeAdminOverrideNotice.platform_label)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${safeArray(executionCard.evidence).length ? `
|
||
<div class="list" style="margin-top:10px;">
|
||
${safeArray(executionCard.evidence).slice(0, 2).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.kind === "skill" ? "技能证据" : "记忆证据")} · ${escapeHtml(item.title || "未命名")}</h4>
|
||
<p>${escapeHtml(item.summary || "暂无摘要")}</p>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
${safeArray(executionCard.next_steps).length ? `
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
${safeArray(executionCard.next_steps).slice(0, 3).map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join("")}
|
||
</div>
|
||
` : ""}
|
||
${secondaryActions.length ? `
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
${secondaryActions.map((item) => actionTag(
|
||
item.label || item.key || "执行",
|
||
item.key || "",
|
||
[
|
||
item.executor_key ? `data-executor-key="${escapeHtml(item.executor_key)}"` : "",
|
||
item.platform ? `data-platform="${escapeHtml(item.platform)}"` : "",
|
||
message.session_id ? `data-session-id="${escapeHtml(message.session_id)}"` : "",
|
||
...Object.entries(item.payload || {}).map(([payloadKey, payloadValue]) => {
|
||
const attrKey = String(payloadKey || "")
|
||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||
.replace(/_/g, "-")
|
||
.toLowerCase();
|
||
const serialized = typeof payloadValue === "string"
|
||
? payloadValue
|
||
: JSON.stringify(payloadValue);
|
||
return `data-${escapeHtml(attrKey)}="${escapeHtml(serialized)}"`;
|
||
})
|
||
].filter(Boolean).join(" ")
|
||
,
|
||
{ disabledReason: item.disabled_reason || "" }
|
||
)).join("")}
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
function renderOneLinerUi() {
|
||
ensureOneLinerUi();
|
||
const fab = document.querySelector(".oneliner-fab");
|
||
const meta = document.querySelector('[data-role="oneliner-meta"]');
|
||
const runs = document.querySelector('[data-role="oneliner-runs"]');
|
||
const sessions = document.querySelector('[data-role="oneliner-sessions"]');
|
||
const messages = document.querySelector('[data-role="oneliner-messages"]');
|
||
const status = document.querySelector('[data-role="oneliner-status"]');
|
||
const input = document.querySelector('[data-role="oneliner-input"]');
|
||
const profile = appState.onelinerProfile;
|
||
const effective = appState.onelinerGovernanceEffective;
|
||
const activeAdminOverrideNotice = effective?.active_admin_override_notice || null;
|
||
const highlights = summarizePolicyHighlights(effective?.effective_policy || {}, effective?.platform || "");
|
||
const layers = safeArray(effective?.layers);
|
||
const currentRun = getCurrentOneLinerRun();
|
||
const activeRuns = safeArray(appState.onelinerRuns).filter((item) => !["done", "failed", "cancelled"].includes(item.run_status));
|
||
if (fab) {
|
||
fab.hidden = !appState.session;
|
||
const mark = fab.querySelector(".oneliner-fab-mark");
|
||
const text = fab.querySelector(".oneliner-fab-text");
|
||
if (mark) mark.textContent = String(activeRuns.length || 1);
|
||
if (text) {
|
||
text.textContent = currentRun
|
||
? `OneLiner · ${currentRun.run_status === "needs_confirmation" ? "待确认" : currentRun.run_status === "running" ? "执行中" : currentRun.run_status === "queued" ? "排队中" : "工作中"}`
|
||
: "OneLiner";
|
||
}
|
||
}
|
||
if (meta) {
|
||
meta.innerHTML = `
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(profile?.display_name || "OneLiner")}</span>
|
||
<span class="tag">${escapeHtml(getSelectedProject()?.name || "未选项目")}</span>
|
||
<span class="tag">${escapeHtml(profile?.default_platform ? platformLabel(profile.default_platform) : "未设默认平台")}</span>
|
||
${profile?.current_version?.version_no ? `<span class="tag">配置 v${escapeHtml(formatNumber(profile.current_version.version_no || 0))}</span>` : ""}
|
||
<span class="tag green">${escapeHtml(formatNumber(safeArray(appState.platformAgents).length))} 个平台 Agent</span>
|
||
</div>
|
||
<div class="helper-text">${escapeHtml(profile?.long_term_goal || "当前没有设置长期目标。你可以先在这里说目标,后续再逐步产品化。")}</div>
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
${layers.map((layer) => `<span class="tag ${layer.scope_kind === "admin_override" ? "orange" : "blue"}">${escapeHtml(policyScopeTagLabel(layer.scope_kind, layer.scope?.platform || effective?.platform || ""))}</span>`).join("") || `<span class="tag">还没有策略层</span>`}
|
||
${highlights.map((item) => `<span class="tag green">${escapeHtml(item)}</span>`).join("")}
|
||
<span class="tag clickable-tag" data-action="open-user-global-policy">我的策略</span>
|
||
</div>
|
||
${activeAdminOverrideNotice?.title ? `
|
||
<div class="task-item compact" style="margin-top:10px; border-color:rgba(245, 158, 11, 0.28); background:linear-gradient(180deg, rgba(255, 250, 240, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);">
|
||
<h4>管理员覆盖生效中</h4>
|
||
<p>${escapeHtml(activeAdminOverrideNotice.summary || "当前主 Agent 会优先遵循管理员覆盖层。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag orange">${escapeHtml(activeAdminOverrideNotice.title || "管理员覆盖")}</span>
|
||
<span class="tag clickable-tag" data-action="open-user-global-policy">查看我的策略</span>
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
`;
|
||
}
|
||
if (runs) runs.innerHTML = renderOneLinerRunsHtml();
|
||
if (sessions) sessions.innerHTML = renderOneLinerSessionTabs();
|
||
if (messages) {
|
||
messages.innerHTML = renderOneLinerMessagesHtml();
|
||
messages.scrollTop = messages.scrollHeight;
|
||
}
|
||
if (status) {
|
||
status.textContent = appState.busy ? appState.message || "处理中..." : "";
|
||
}
|
||
if (input && !input.value && !safeArray(appState.onelinerMessages).length) {
|
||
input.value = "";
|
||
}
|
||
syncOneLinerRunPolling();
|
||
}
|
||
|
||
function clearOneLinerRunPollTimer() {
|
||
if (onelinerRunPollTimer) {
|
||
clearTimeout(onelinerRunPollTimer);
|
||
onelinerRunPollTimer = null;
|
||
}
|
||
}
|
||
|
||
function syncOneLinerRunPolling() {
|
||
clearOneLinerRunPollTimer();
|
||
const panel = document.querySelector(".oneliner-backdrop");
|
||
const isPanelVisible = Boolean(panel && !panel.classList.contains("hidden"));
|
||
const currentRun = getCurrentOneLinerRun();
|
||
const shouldPoll = Boolean(
|
||
isPanelVisible
|
||
&& currentRun?.id
|
||
&& ["queued", "running", "blocked"].includes(currentRun.run_status)
|
||
);
|
||
if (!shouldPoll) {
|
||
return;
|
||
}
|
||
onelinerRunPollTimer = setTimeout(async () => {
|
||
try {
|
||
await hydrateSelectedOneLinerRun();
|
||
renderAll();
|
||
} catch {
|
||
clearOneLinerRunPollTimer();
|
||
}
|
||
}, 1500);
|
||
}
|
||
|
||
function openOneLinerPanel() {
|
||
ensureOneLinerUi();
|
||
document.body.classList.add("sheet-open", "oneliner-open");
|
||
document.querySelector(".oneliner-backdrop")?.classList.remove("hidden");
|
||
syncOneLinerRunPolling();
|
||
}
|
||
|
||
function closeOneLinerPanel() {
|
||
document.body.classList.remove("sheet-open", "oneliner-open");
|
||
document.querySelector(".oneliner-backdrop")?.classList.add("hidden");
|
||
clearOneLinerRunPollTimer();
|
||
}
|
||
|
||
function readActionForm() {
|
||
const values = {};
|
||
document.querySelectorAll("[data-action-field]").forEach((element) => {
|
||
const name = element.getAttribute("data-action-field");
|
||
if (!name) return;
|
||
if (element instanceof HTMLInputElement && element.type === "checkbox") {
|
||
values[name] = element.checked;
|
||
return;
|
||
}
|
||
if (element instanceof HTMLInputElement && element.type === "file") {
|
||
values[name] = element.files?.[0] || null;
|
||
return;
|
||
}
|
||
values[name] = element.value;
|
||
});
|
||
return values;
|
||
}
|
||
|
||
async function submitActionModal() {
|
||
if (!currentActionConfig?.onSubmit) return;
|
||
const message = document.querySelector('[data-role="action-message"]');
|
||
const submit = document.querySelector('[data-action="submit-sheet"]');
|
||
const values = readActionForm();
|
||
if (submit) submit.disabled = true;
|
||
if (message) message.textContent = "正在执行...";
|
||
try {
|
||
const result = await currentActionConfig.onSubmit(values);
|
||
if (result?.keepOpen) {
|
||
if (message) message.textContent = result.message || "已完成";
|
||
} else {
|
||
closeActionModal();
|
||
}
|
||
} catch (error) {
|
||
if (message) message.textContent = formatActionErrorMessage(error);
|
||
if (submit) submit.disabled = false;
|
||
return;
|
||
}
|
||
if (submit) submit.disabled = false;
|
||
}
|
||
|
||
async function storyforgeFetch(path, options = {}) {
|
||
return API_CLIENT.fetchJson(path, options);
|
||
}
|
||
|
||
async function storyforgeFetchBlob(path, options = {}) {
|
||
return API_CLIENT.fetchBlob(path, options);
|
||
}
|
||
|
||
async function loadBackendCapabilities(backendUrl) {
|
||
return API_CLIENT.loadBackendCapabilities(backendUrl);
|
||
}
|
||
|
||
function backendSupports(path) {
|
||
return API_CLIENT.backendSupports(path);
|
||
}
|
||
|
||
async function loginWithAutoSession(backendUrl = DEFAULT_BACKEND_URL) {
|
||
const payload = await storyforgeFetch("/v2/auth/auto-session", {
|
||
backendUrl,
|
||
auth: false,
|
||
method: "POST",
|
||
body: {}
|
||
});
|
||
persistSession({
|
||
backendUrl,
|
||
token: payload.token,
|
||
account: payload.account
|
||
});
|
||
appState.autoConnectError = "";
|
||
appState.autoConnectAttempted = true;
|
||
appState.autoConnectSuppressed = false;
|
||
}
|
||
|
||
async function ensureAutoSession(options = {}) {
|
||
const backendUrl = options.backendUrl || readAuthForm().backendUrl || DEFAULT_BACKEND_URL;
|
||
const force = Boolean(options.force);
|
||
const backendMismatch = hasSessionBackendMismatch(backendUrl);
|
||
if (backendMismatch) {
|
||
persistSession(null);
|
||
}
|
||
if (appState.session && !force) {
|
||
return true;
|
||
}
|
||
if (appState.autoConnectSuppressed && !force) {
|
||
return false;
|
||
}
|
||
if (appState.autoConnectAttempted && !force) {
|
||
return Boolean(appState.session);
|
||
}
|
||
appState.autoConnectAttempted = true;
|
||
renderAll();
|
||
try {
|
||
await loginWithAutoSession(backendUrl);
|
||
return true;
|
||
} catch (error) {
|
||
appState.autoConnectError = formatActionErrorMessage(error, "自动连接失败");
|
||
persistSession(null);
|
||
renderAll();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function isAutoConnectionPending() {
|
||
return !appState.session
|
||
&& appState.autoConnectAttempted
|
||
&& !appState.autoConnectSuppressed
|
||
&& !appState.autoConnectError;
|
||
}
|
||
|
||
function renderAutoConnectingScreen(screenTitle, nextStepText) {
|
||
return screenShell(
|
||
screenTitle,
|
||
"正在自动连接工作区,成功后会自动替换成真实内容。",
|
||
`${button("连接状态", "open-auth", "primary")}`,
|
||
renderEmptyState(
|
||
"正在自动连接工作区",
|
||
nextStepText || "如果超过几秒仍未进入真实页面,可以点右上角查看连接状态。"
|
||
)
|
||
);
|
||
}
|
||
|
||
async function refreshFromAuthModal() {
|
||
const modal = document.querySelector(".auth-modal-backdrop");
|
||
const visible = modal && !modal.classList.contains("hidden");
|
||
if (!visible) {
|
||
await bootstrap();
|
||
return;
|
||
}
|
||
appState.autoConnectSuppressed = false;
|
||
appState.autoConnectAttempted = false;
|
||
await ensureAutoSession({ force: true });
|
||
if (appState.session) closeAuthModal();
|
||
await bootstrap();
|
||
}
|
||
|
||
async function logoutSession() {
|
||
try {
|
||
if (appState.session) {
|
||
await storyforgeFetch("/v2/auth/logout", { method: "POST" });
|
||
}
|
||
} catch {}
|
||
persistSession(null);
|
||
appState.autoConnectAttempted = true;
|
||
appState.autoConnectSuppressed = true;
|
||
appState.autoConnectError = "当前会话已退出。需要时可以点右上角重新自动连接。";
|
||
appState.me = null;
|
||
appState.dashboard = null;
|
||
appState.contentSources = [];
|
||
appState.accounts = [];
|
||
appState.selectedAccountId = "";
|
||
appState.currentPlatform = "";
|
||
appState.selectedAssistantId = "";
|
||
appState.selectedWorkspace = null;
|
||
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
|
||
appState.snapshots = [];
|
||
appState.selectedSnapshotId = "";
|
||
appState.selectedSnapshotDetail = null;
|
||
appState.creatorFields = null;
|
||
appState.analysisReports = [];
|
||
appState.documents = [];
|
||
appState.trackingAccounts = [];
|
||
appState.trackingDigest = null;
|
||
appState.reviews = [];
|
||
appState.onelinerProfile = null;
|
||
appState.onelinerSessions = [];
|
||
appState.selectedOnelinerSessionId = "";
|
||
appState.onelinerRuns = [];
|
||
appState.selectedOnelinerRunId = "";
|
||
appState.lastCompletedOnelinerRunId = "";
|
||
appState.onelinerMessages = [];
|
||
appState.onelinerActionRegistry = [];
|
||
appState.platformAgents = [];
|
||
appState.onelinerGovernanceEffective = null;
|
||
appState.userGlobalPolicy = null;
|
||
appState.userCurrentPlatformPolicy = null;
|
||
appState.userPolicyAudits = [];
|
||
appState.adminSystemMainPolicy = null;
|
||
appState.adminSystemPlatformPolicies = [];
|
||
appState.adminGovernanceDirectory = null;
|
||
appState.adminOverrideTarget = null;
|
||
appState.adminOverridePolicy = null;
|
||
appState.adminPolicyAudits = [];
|
||
appState.tenantQuota = null;
|
||
appState.tenantUsage = null;
|
||
appState.adminOpsOverview = null;
|
||
appState.adminFixRuns = [];
|
||
clearRecoveryRecords();
|
||
appState.integrationHealth = null;
|
||
appState.storageStatus = null;
|
||
appState.liveRecorderHealth = null;
|
||
appState.backendCapabilities = null;
|
||
appState.lastAction = null;
|
||
appState.lastGeneratedCopy = null;
|
||
appState.lastSimilaritySearch = null;
|
||
appState.lastJobDetail = null;
|
||
localStorage.removeItem(STORAGE_KEY + ":currentPlatform");
|
||
renderAll();
|
||
}
|
||
|
||
async function loadKnowledgeDocuments(knowledgeBases) {
|
||
const targets = safeArray(knowledgeBases).slice(0, 3);
|
||
if (!targets.length) return [];
|
||
const groups = await Promise.all(
|
||
targets.map((kb) =>
|
||
storyforgeFetch(`/v2/knowledge-bases/${encodeURIComponent(kb.id)}/documents`).catch(() => [])
|
||
)
|
||
);
|
||
return groups.flat().slice(0, 12);
|
||
}
|
||
|
||
async function loadStorageStatus(projectId = "") {
|
||
const suffix = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
|
||
const payload = await storyforgeFetch(`/v2/storage/status${suffix}`).catch(() => null);
|
||
appState.storageStatus = payload;
|
||
return payload;
|
||
}
|
||
|
||
async function hydrateSelectedOneLinerRun() {
|
||
const runId = appState.selectedOnelinerRunId || "";
|
||
if (!runId) {
|
||
return null;
|
||
}
|
||
const detail = await storyforgeFetch(`/v2/oneliner/runs/${encodeURIComponent(runId)}`).catch(() => null);
|
||
if (!detail?.id) return null;
|
||
const runs = safeArray(appState.onelinerRuns);
|
||
const nextRuns = runs.some((item) => item.id === detail.id)
|
||
? runs.map((item) => (item.id === detail.id ? detail : item))
|
||
: [detail, ...runs];
|
||
appState.onelinerRuns = nextRuns;
|
||
if (detail.run_status === "done" && detail.id && appState.lastCompletedOnelinerRunId !== detail.id) {
|
||
rememberAction("主 Agent 已完成本轮", detail.result?.execution_summary || detail.status_summary || detail.summary || "当前运行已经完成,可以继续执行下一步。", "green", detail);
|
||
appState.lastCompletedOnelinerRunId = detail.id;
|
||
}
|
||
return detail;
|
||
}
|
||
|
||
async function loadAgentControlSurfaces(projectId = "") {
|
||
const normalizedProjectId = projectId || getOneLinerProjectId();
|
||
const governancePlatform = normalizePlatformValue(getPreferredPlatform(), "douyin");
|
||
const [profile, sessionsPayload, runsPayload, actionRegistryPayload, platformAgentsPayload, governanceEffective, userGlobalPolicy, userCurrentPlatformPolicy, userPolicyAuditsPayload, adminSystemMainPolicy, adminSystemPlatformPolicies, adminGovernanceDirectory, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([
|
||
storyforgeFetch(`/v2/oneliner/profile?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null),
|
||
storyforgeFetch(`/v2/oneliner/sessions?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => ({ items: [] })),
|
||
storyforgeFetch(`/v2/oneliner/runs?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => ({ items: [] })),
|
||
storyforgeFetch(`/v2/oneliner/action-registry?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => ({ items: [] })),
|
||
storyforgeFetch(`/v2/platform-agents?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => ({ items: [] })),
|
||
storyforgeFetch(`/v2/oneliner/governance/effective?project_id=${encodeURIComponent(normalizedProjectId)}&platform=${encodeURIComponent(governancePlatform)}`).catch(() => null),
|
||
storyforgeFetch(`/v2/oneliner/governance/user/global?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null),
|
||
storyforgeFetch(`/v2/oneliner/governance/user/platforms/${encodeURIComponent(governancePlatform)}?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null),
|
||
storyforgeFetch(`/v2/oneliner/governance/user/audits?project_id=${encodeURIComponent(normalizedProjectId)}&platform=${encodeURIComponent(governancePlatform)}`).catch(() => ({ items: [] })),
|
||
isSuperAdmin()
|
||
? storyforgeFetch("/v2/admin/oneliner/governance/system/main-agent").catch(() => null)
|
||
: Promise.resolve(null),
|
||
isSuperAdmin()
|
||
? Promise.all(ACTIVE_PLATFORMS.map((item) =>
|
||
storyforgeFetch(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(item.value)}`).catch(() => null)
|
||
))
|
||
: Promise.resolve([]),
|
||
isSuperAdmin()
|
||
? storyforgeFetch("/v2/admin/oneliner/governance/directory").catch(() => ({ items: [] }))
|
||
: Promise.resolve({ items: [] }),
|
||
storyforgeFetch(`/v2/tenant/quota?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null),
|
||
storyforgeFetch(`/v2/tenant/usage?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null),
|
||
isSuperAdmin()
|
||
? storyforgeFetch("/v2/admin/ops/overview").catch(() => null)
|
||
: Promise.resolve(null),
|
||
isSuperAdmin()
|
||
? storyforgeFetch("/v2/admin/ops/fix-runs").catch(() => ({ items: [] }))
|
||
: Promise.resolve({ items: [] })
|
||
]);
|
||
|
||
appState.onelinerProfile = profile;
|
||
appState.onelinerSessions = safeArray(sessionsPayload?.items || sessionsPayload);
|
||
appState.onelinerRuns = safeArray(runsPayload?.items || runsPayload);
|
||
appState.onelinerActionRegistry = safeArray(actionRegistryPayload?.items || actionRegistryPayload);
|
||
if (!appState.selectedOnelinerSessionId || !safeArray(appState.onelinerSessions).some((item) => item.id === appState.selectedOnelinerSessionId)) {
|
||
appState.selectedOnelinerSessionId = safeArray(appState.onelinerSessions)[0]?.id || "";
|
||
}
|
||
appState.selectedOnelinerRunId = choosePreferredOneLinerRunId(appState.onelinerRuns, appState.selectedOnelinerRunId || "");
|
||
if (appState.selectedOnelinerRunId) {
|
||
await hydrateSelectedOneLinerRun();
|
||
}
|
||
appState.platformAgents = safeArray(platformAgentsPayload?.items || platformAgentsPayload);
|
||
appState.onelinerGovernanceEffective = governanceEffective;
|
||
appState.userGlobalPolicy = userGlobalPolicy;
|
||
appState.userCurrentPlatformPolicy = userCurrentPlatformPolicy;
|
||
appState.userPolicyAudits = safeArray(userPolicyAuditsPayload?.items || userPolicyAuditsPayload);
|
||
appState.adminSystemMainPolicy = adminSystemMainPolicy;
|
||
appState.adminSystemPlatformPolicies = safeArray(adminSystemPlatformPolicies);
|
||
appState.adminGovernanceDirectory = safeArray(adminGovernanceDirectory?.items || adminGovernanceDirectory);
|
||
if (isSuperAdmin() && appState.adminGovernanceDirectory.length) {
|
||
const existingTarget = appState.adminOverrideTarget || {};
|
||
const hasExistingProjectTarget = Object.prototype.hasOwnProperty.call(existingTarget, "targetProjectId")
|
||
|| Object.prototype.hasOwnProperty.call(existingTarget, "target_project_id");
|
||
const targetUserId = String(existingTarget.targetUserId || existingTarget.target_user_id || appState.adminGovernanceDirectory[0]?.id || "");
|
||
const targetUser = appState.adminGovernanceDirectory.find((item) => item.id === targetUserId) || appState.adminGovernanceDirectory[0] || null;
|
||
const targetProjects = safeArray(targetUser?.projects);
|
||
const requestedProjectId = hasExistingProjectTarget
|
||
? String(existingTarget.targetProjectId ?? existingTarget.target_project_id ?? "")
|
||
: String(targetProjects[0]?.id || "");
|
||
const targetProjectId = requestedProjectId === ""
|
||
? ""
|
||
: (targetProjects.some((item) => String(item?.id || "") === requestedProjectId) ? requestedProjectId : String(targetProjects[0]?.id || ""));
|
||
const targetPlatform = normalizePlatformValue(existingTarget.platform || governancePlatform, "douyin");
|
||
appState.adminOverrideTarget = {
|
||
targetUserId,
|
||
targetProjectId,
|
||
platform: targetPlatform
|
||
};
|
||
appState.adminOverridePolicy = await storyforgeFetch(`/v2/admin/oneliner/governance/overrides?target_user_id=${encodeURIComponent(targetUserId)}&target_project_id=${encodeURIComponent(targetProjectId)}&platform=${encodeURIComponent(targetPlatform)}`).catch(() => null);
|
||
appState.adminPolicyAudits = safeArray((await storyforgeFetch(`/v2/admin/oneliner/governance/audits?target_user_id=${encodeURIComponent(targetUserId)}&target_project_id=${encodeURIComponent(targetProjectId)}&platform=${encodeURIComponent(targetPlatform)}&include_system=1`).catch(() => ({ items: [] })))?.items || []);
|
||
} else {
|
||
appState.adminOverrideTarget = null;
|
||
appState.adminOverridePolicy = null;
|
||
appState.adminPolicyAudits = [];
|
||
}
|
||
appState.tenantQuota = tenantQuota;
|
||
appState.tenantUsage = tenantUsage;
|
||
appState.adminOpsOverview = adminOpsOverview;
|
||
appState.adminFixRuns = safeArray(adminFixRunsPayload?.items || adminFixRunsPayload);
|
||
}
|
||
|
||
async function loadOneLinerMessages(sessionId) {
|
||
if (!sessionId) {
|
||
appState.onelinerMessages = [];
|
||
return [];
|
||
}
|
||
const payload = await storyforgeFetch(`/v2/oneliner/sessions/${encodeURIComponent(sessionId)}/messages`).catch(() => ({ items: [] }));
|
||
appState.onelinerMessages = safeArray(payload?.items || payload);
|
||
return appState.onelinerMessages;
|
||
}
|
||
|
||
async function ensureOneLinerSession() {
|
||
const projectId = getOneLinerProjectId();
|
||
if (!projectId) throw new Error("当前还没有项目,OneLiner 需要先绑定项目上下文。");
|
||
let session = getCurrentOneLinerSession();
|
||
if (!session) {
|
||
session = await storyforgeFetch("/v2/oneliner/sessions", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: projectId,
|
||
preferred_platform: getPreferredPlatform()
|
||
}
|
||
});
|
||
appState.onelinerSessions = [session, ...safeArray(appState.onelinerSessions)];
|
||
appState.selectedOnelinerSessionId = session.id;
|
||
}
|
||
await loadOneLinerMessages(session.id);
|
||
return session;
|
||
}
|
||
|
||
async function submitOneLinerMessage(content) {
|
||
const projectId = getOneLinerProjectId();
|
||
const session = await ensureOneLinerSession();
|
||
const payload = await storyforgeFetch(`/v2/oneliner/sessions/${encodeURIComponent(session.id)}/messages`, {
|
||
method: "POST",
|
||
body: {
|
||
content,
|
||
project_id: projectId,
|
||
platform: getPreferredPlatform()
|
||
}
|
||
});
|
||
appState.selectedOnelinerSessionId = payload.session?.id || session.id;
|
||
await loadAgentControlSurfaces(projectId);
|
||
if (appState.selectedOnelinerSessionId) {
|
||
await loadOneLinerMessages(appState.selectedOnelinerSessionId);
|
||
} else {
|
||
appState.onelinerMessages = [
|
||
...safeArray(appState.onelinerMessages),
|
||
payload.user_message,
|
||
payload.assistant_message
|
||
].filter(Boolean);
|
||
}
|
||
appState.onelinerProfile = payload.result?.context?.oneliner_profile || appState.onelinerProfile || null;
|
||
rememberAction("OneLiner 已响应", payload.result?.summary_text || "已返回一版任务拆解。", "blue", payload);
|
||
return payload;
|
||
}
|
||
|
||
async function createOneLinerRun(runRequest) {
|
||
const projectId = getOneLinerProjectId();
|
||
const payload = await storyforgeFetch("/v2/oneliner/runs", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: projectId,
|
||
platform: getPreferredPlatform(),
|
||
platform_scope: "single_platform",
|
||
delivery_mode: "hybrid",
|
||
scheduling_mode: "queued",
|
||
...runRequest
|
||
}
|
||
});
|
||
await loadAgentControlSurfaces(projectId);
|
||
appState.selectedOnelinerRunId = payload?.id || choosePreferredOneLinerRunId(appState.onelinerRuns, "");
|
||
rememberAction("主 Agent 已接单", payload?.title || payload?.plan?.goal || "已创建待确认任务。", "blue", payload);
|
||
return payload;
|
||
}
|
||
|
||
async function confirmOneLinerRun(runId, reason = "") {
|
||
const payload = await storyforgeFetch(`/v2/oneliner/runs/${encodeURIComponent(runId)}/confirm`, {
|
||
method: "POST",
|
||
body: { reason }
|
||
});
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
appState.selectedOnelinerRunId = payload?.id || runId;
|
||
rememberAction("主 Agent 已确认执行", payload?.status_summary || "当前任务已进入执行流。", "green", payload);
|
||
return payload;
|
||
}
|
||
|
||
async function cancelOneLinerRun(runId, reason = "") {
|
||
const payload = await storyforgeFetch(`/v2/oneliner/runs/${encodeURIComponent(runId)}/cancel`, {
|
||
method: "POST",
|
||
body: { reason }
|
||
});
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
appState.selectedOnelinerRunId = choosePreferredOneLinerRunId(appState.onelinerRuns, "");
|
||
rememberAction("主 Agent 任务已取消", payload?.status_summary || "当前任务已取消。", "orange", payload);
|
||
return payload;
|
||
}
|
||
|
||
async function retryOneLinerRun(runId, reason = "") {
|
||
const payload = await storyforgeFetch(`/v2/oneliner/runs/${encodeURIComponent(runId)}/retry`, {
|
||
method: "POST",
|
||
body: { reason }
|
||
});
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
appState.selectedOnelinerRunId = payload?.id || choosePreferredOneLinerRunId(appState.onelinerRuns, "");
|
||
appState.onelinerRunFilter = "focus";
|
||
rememberAction("主 Agent 已重新生成待确认卡", payload?.title || payload?.plan?.goal || "你可以先确认新的执行计划。", "green", payload);
|
||
return payload;
|
||
}
|
||
|
||
function renderOneLinerExecutionPayloadHtml(payload) {
|
||
if (!payload || typeof payload !== "object") {
|
||
return `<div class="task-item compact"><h4>没有返回执行结果</h4><p>当前执行器没有附带额外数据。</p></div>`;
|
||
}
|
||
if (payload.result_kind === "main_agent_plan") {
|
||
const landingRunId = String(payload.run_id || "").trim();
|
||
const landingScreen = String(payload.recommended_action?.screen || "").trim();
|
||
const landingTitle = String(payload.goal || "主 Agent 执行建议").trim();
|
||
const landingSummary = String(
|
||
payload.recommended_action?.summary || payload.execution_summary || payload.summary_text || ""
|
||
).trim();
|
||
const resultSections = payload.result_sections && typeof payload.result_sections === "object"
|
||
? payload.result_sections
|
||
: {};
|
||
const resultCards = safeArray(resultSections.cards).slice(0, 4);
|
||
const configVersion = payload.execution_card?.oneliner_profile_version || payload.context?.oneliner_profile?.current_version || {};
|
||
const platformAgentProfile = payload.execution_card?.platform_agent_profile || {};
|
||
const landingAttrs = buildMainAgentLandingAttrs({
|
||
runId: landingRunId,
|
||
screen: landingScreen,
|
||
title: landingTitle,
|
||
summary: landingSummary
|
||
});
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(payload.goal || "主 Agent 执行建议")}</h4>
|
||
<p>${escapeHtml(payload.execution_summary || payload.summary_text || "已形成一版可继续执行的主 Agent 建议。")}</p>
|
||
<div class="task-meta">
|
||
${payload.platform ? `<span class="tag blue">${escapeHtml(platformLabel(payload.platform))}</span>` : ""}
|
||
<span class="tag">${escapeHtml(payload.platform_scope === "all_platforms" ? "全平台" : "单平台")}</span>
|
||
${configVersion.version_no ? `<span class="tag">配置 v${escapeHtml(formatNumber(configVersion.version_no || 0))}</span>` : ""}
|
||
<span class="tag green">已收口</span>
|
||
${payload.recommended_action?.action ? `<span class="tag clickable-tag" data-action="${escapeHtml(payload.recommended_action.action)}" data-main-agent-run-id="${escapeHtml(landingRunId)}" data-main-agent-screen="${escapeHtml(landingScreen)}" data-main-agent-title="${escapeHtml(landingTitle)}" data-main-agent-summary="${escapeHtml(landingSummary)}" ${landingAttrs}>${escapeHtml(payload.recommended_action.label || "回到对应页面")}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
${configVersion.version_no ? `
|
||
<div class="task-item compact" style="margin-top:12px;">
|
||
<h4>本轮使用的主配置</h4>
|
||
<p>${escapeHtml(configVersion.summary || configVersion.title || `OneLiner 主配置版本 v${formatNumber(configVersion.version_no || 0)}`)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">配置 v${escapeHtml(formatNumber(configVersion.version_no || 0))}</span>
|
||
<span class="tag clickable-tag" data-action="open-oneliner-profile-history">查看配置历史</span>
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${platformAgentProfile.platform ? `
|
||
<div class="task-item compact" style="margin-top:12px;">
|
||
<h4>本轮平台 Agent</h4>
|
||
<p>${escapeHtml(platformAgentProfile.mission || platformAgentProfile.name || `${platformAgentProfile.platform_label || platformLabel(platformAgentProfile.platform)} Agent 正在承接这轮执行。`)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(platformAgentProfile.platform_label || platformLabel(platformAgentProfile.platform))}</span>
|
||
${platformAgentProfile.name ? `<span class="tag">${escapeHtml(platformAgentProfile.name)}</span>` : ""}
|
||
${platformAgentProfile.assistant_name ? `<span class="tag green">${escapeHtml(platformAgentProfile.assistant_name)}</span>` : ""}
|
||
${platformAgentProfile.version_no ? `<span class="tag">${escapeHtml(platformLabel(platformAgentProfile.platform || payload.platform || ""))} Agent v${escapeHtml(formatNumber(platformAgentProfile.version_no || 0))}</span>` : ""}
|
||
${platformAgentProfile.platform && platformAgentProfile.version_no ? `<span class="tag clickable-tag" data-action="open-platform-agent-profile-history" data-platform="${escapeHtml(platformAgentProfile.platform)}">看平台配置历史</span>` : ""}
|
||
${platformAgentProfile.readiness_label ? `<span class="tag ${platformAgentProfile.readiness_score >= 75 ? "green" : platformAgentProfile.readiness_score >= 50 ? "blue" : "orange"}">${escapeHtml(platformAgentProfile.readiness_label)} ${escapeHtml(formatNumber(platformAgentProfile.readiness_score || 0))}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${resultCards.length ? `
|
||
<div class="task-item compact" style="margin-top:12px;">
|
||
<h4>${escapeHtml(resultSections.workstream_label || "执行结果分组")}</h4>
|
||
<p>${escapeHtml(payload.summary_text || "主 Agent 已将当前执行结果整理为可继续推进的结构化建议。")}</p>
|
||
<div class="detail-grid" style="margin-top:12px;">
|
||
${resultCards.map((card, index) => {
|
||
const tags = safeArray(card?.tags).slice(0, 3);
|
||
const tone = String(card?.tone || "").trim();
|
||
const toneClass = tone === "orange" ? "orange" : tone === "green" ? "green" : "blue";
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(card?.title || (index === 0 ? "当前焦点" : "执行结果"))}</h4>
|
||
<p>${escapeHtml(card?.body || "主 Agent 已生成一条可继续执行的结果说明。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${toneClass}">${escapeHtml(card?.title || (index === 0 ? "当前焦点" : "结果卡片"))}</span>
|
||
${tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${payload.recommended_action?.summary ? `
|
||
<div class="task-item compact" style="margin-top:12px;">
|
||
<h4>建议回跳</h4>
|
||
<p>${escapeHtml(payload.recommended_action.summary)}</p>
|
||
</div>
|
||
` : ""}
|
||
${safeArray(payload.next_steps).length ? `
|
||
<div class="list" style="margin-top:12px;">
|
||
${safeArray(payload.next_steps).slice(0, 4).map((step, index) => `
|
||
<div class="task-item compact">
|
||
<h4>下一步 ${escapeHtml(formatNumber(index + 1))}</h4>
|
||
<p>${escapeHtml(step)}</p>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
`;
|
||
}
|
||
if (payload.job) {
|
||
const job = payload.job || {};
|
||
const sourceJob = payload.source_job || {};
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(job.title || "任务已创建")}</h4>
|
||
<p>${escapeHtml(payload.brief || sourceJob.title || `已创建 ${job.line_type || job.source_type || "任务"},你可以继续进入生产中心查看进度。`)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(job.line_type || job.source_type || "analysis")}</span>
|
||
<span class="tag ${job.status === "completed" ? "green" : "orange"}">${escapeHtml(job.status || "queued")}</span>
|
||
${job.id ? `<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(job.id)}">看任务详情</span>` : ""}
|
||
<span class="tag clickable-tag" data-action="goto-production">去生产中心</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
if (payload.saved || payload.started) {
|
||
const saved = payload.saved || {};
|
||
const item = saved.item || {};
|
||
const started = payload.started || {};
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.binding_title || payload.source_url || "录制源已保存")}</h4>
|
||
<p>${escapeHtml(item.source_url || payload.source_url || "直播源已经保存到当前租户的录制配置。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(platformLabel(payload.platform || item.platform || "kuaishou"))}</span>
|
||
<span class="tag">${escapeHtml(item.quality || "原画")}</span>
|
||
<span class="tag ${started && started.ok === false ? "orange" : "green"}">${escapeHtml(started && started.ok === false ? "启动待重试" : "已同步")}</span>
|
||
<span class="tag clickable-tag" data-action="open-live-recorder">打开录制控制</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
if (payload.analyzed_count !== undefined && safeArray(payload.items).length) {
|
||
return `
|
||
<div class="detail-grid">
|
||
<div class="mini-card"><small>平台</small><strong>${escapeHtml(platformLabel(payload.platform || payload.account?.platform || "douyin"))}</strong></div>
|
||
<div class="mini-card"><small>账号</small><strong>${escapeHtml(payload.account?.title || payload.account?.handle || payload.account?.source_url || "当前账号")}</strong></div>
|
||
<div class="mini-card"><small>拆解作品</small><strong>${escapeHtml(formatNumber(payload.analyzed_count || 0))}</strong></div>
|
||
<div class="mini-card"><small>已写记忆</small><strong>${escapeHtml(payload.memory?.title ? "是" : "否")}</strong></div>
|
||
</div>
|
||
<div class="list" style="margin-top:12px;">
|
||
${safeArray(payload.items).slice(0, 4).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.video_title || "高分作品")}</h4>
|
||
<p>${escapeHtml(item.summary_text || "已完成拆解。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">得分 ${escapeHtml(formatNumber(item.performance_score || 0))}</span>
|
||
${item.latest_job_id ? `<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(item.latest_job_id)}">看原分析</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
if (payload.route_checks) {
|
||
return `
|
||
<div class="detail-grid">
|
||
<div class="mini-card"><small>平台</small><strong>${escapeHtml(payload.platform_label || payload.platform || "-")}</strong></div>
|
||
<div class="mini-card"><small>得分</small><strong>${escapeHtml(formatNumber(payload.score || 0))}</strong></div>
|
||
<div class="mini-card"><small>状态</small><strong>${escapeHtml(payload.readiness_label || payload.verdict || "-")}</strong></div>
|
||
<div class="mini-card"><small>账号源</small><strong>${escapeHtml(formatNumber(payload.source_count || 0))}</strong></div>
|
||
</div>
|
||
<div class="two-col" style="margin-top:12px;">
|
||
<div>
|
||
<div class="panel-subtitle">路由检查</div>
|
||
<div class="list" style="margin-top:8px;">
|
||
${safeArray(payload.route_checks).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.key || item.path || "route")}</h4>
|
||
<p>${escapeHtml(item.path || "")}</p>
|
||
<div class="task-meta"><span class="tag ${item.ok ? "green" : "orange"}">${escapeHtml(item.ok ? "可用" : "缺失")}</span></div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="panel-subtitle">建议动作</div>
|
||
<div class="list" style="margin-top:8px;">
|
||
${(safeArray(payload.suggestions).length ? safeArray(payload.suggestions) : ["当前已经达到可运行状态。"]).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>下一步</h4>
|
||
<p>${escapeHtml(item)}</p>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
if (payload.strategy && payload.tenant_usage) {
|
||
return `
|
||
<div class="detail-grid">
|
||
<div class="mini-card"><small>jobs</small><strong>${escapeHtml(payload.tenant_usage?.project_jobs?.human_size || "0B")}</strong></div>
|
||
<div class="mini-card"><small>downloads</small><strong>${escapeHtml(payload.tenant_usage?.project_downloads?.human_size || "0B")}</strong></div>
|
||
<div class="mini-card"><small>模型目录</small><strong>${escapeHtml(payload.strategy?.models?.mode || "-")}</strong></div>
|
||
<div class="mini-card"><small>录像</small><strong>${escapeHtml(payload.strategy?.live_recorder?.mode || "-")}</strong></div>
|
||
</div>
|
||
`;
|
||
}
|
||
if (payload.items || payload.files) {
|
||
return `
|
||
<div class="detail-grid">
|
||
<div class="mini-card"><small>录制源</small><strong>${escapeHtml(formatNumber(safeArray(payload.items).length))}</strong></div>
|
||
<div class="mini-card"><small>最近文件</small><strong>${escapeHtml(formatNumber(safeArray(payload.files).length))}</strong></div>
|
||
</div>
|
||
`;
|
||
}
|
||
if (payload.content) {
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>生成文案</h4>
|
||
<p>${escapeHtml(brief(payload.content, 1200))}</p>
|
||
<div class="task-meta">
|
||
${payload.assistant_id ? `<span class="tag blue">${escapeHtml(payload.assistant_id)}</span>` : ""}
|
||
<span class="tag">${escapeHtml(formatNumber(safeArray(payload.used_documents).length))} 个参考素材</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
if (payload.verdict !== undefined || payload.next_actions !== undefined || payload.highlights !== undefined) {
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(payload.title || "复盘草稿")}</h4>
|
||
<p>${escapeHtml(payload.highlights || payload.notes || "已生成一版待补充的复盘草稿。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(platformLabel(payload.platform || "douyin"))}</span>
|
||
<span class="tag">${escapeHtml(payload.verdict || "待补充")}</span>
|
||
${payload.source_job_id ? `<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(payload.source_job_id)}">看任务详情</span>` : ""}
|
||
${payload.id ? `<span class="tag clickable-tag" data-action="open-review-edit" data-review-id="${escapeHtml(payload.id)}">打开复盘</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>原始结果</h4>
|
||
<p>${escapeHtml(brief(JSON.stringify(payload, null, 2), 1200))}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function parseOneLinerActionPayloadValue(value) {
|
||
const text = String(value ?? "").trim();
|
||
if (!text) return "";
|
||
if (text === "true") return true;
|
||
if (text === "false") return false;
|
||
if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
|
||
if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) {
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch (error) {
|
||
return text;
|
||
}
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function collectOneLinerActionPayload(action) {
|
||
const reserved = new Set(["action", "executorKey", "platform", "sessionId", "disabledReason"]);
|
||
const payload = {};
|
||
Object.entries(action?.dataset || {}).forEach(([key, value]) => {
|
||
if (reserved.has(key)) return;
|
||
if (value === undefined || value === null || value === "") return;
|
||
payload[key] = parseOneLinerActionPayloadValue(value);
|
||
});
|
||
return payload;
|
||
}
|
||
|
||
async function executeOneLinerAction(executorKey, options = {}) {
|
||
const projectId = getOneLinerProjectId();
|
||
const session = getCurrentOneLinerSession() || await ensureOneLinerSession();
|
||
const payload = await storyforgeFetch("/v2/oneliner/actions/execute", {
|
||
method: "POST",
|
||
body: {
|
||
action_key: executorKey,
|
||
project_id: projectId,
|
||
platform: options.platform || getPreferredPlatform(),
|
||
session_id: options.sessionId || session?.id || "",
|
||
payload: options.payload || {}
|
||
}
|
||
});
|
||
await loadAgentControlSurfaces(projectId);
|
||
if (appState.selectedOnelinerSessionId) {
|
||
await loadOneLinerMessages(appState.selectedOnelinerSessionId);
|
||
}
|
||
openActionModal({
|
||
title: payload.title || "OneLiner 执行结果",
|
||
description: payload.summary || "已完成一次对话内执行。",
|
||
hideSubmit: true,
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "执行结果",
|
||
html: `<div class="sheet-html">${renderOneLinerExecutionPayloadHtml(payload.payload || {})}</div>`
|
||
}
|
||
]
|
||
});
|
||
rememberAction("OneLiner 已执行", payload.summary || "当前动作已在对话内执行。", "green", payload);
|
||
renderAll();
|
||
return payload;
|
||
}
|
||
|
||
function openCurrentOneLinerRunResultAction(runId = "") {
|
||
openOneLinerRunContextAction(runId)
|
||
.then((currentRun) => {
|
||
if (!currentRun?.id) {
|
||
rememberAction("还没有可查看的结果", "当前主 Agent 任务还没有返回可展示的执行结果。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
if (!currentRun.result || !Object.keys(currentRun.result || {}).length) {
|
||
rememberAction("结果还在生成中", currentRun.status_summary || "当前主 Agent 任务还没有返回执行结果。", "orange", currentRun);
|
||
renderAll();
|
||
return;
|
||
}
|
||
appState.selectedOnelinerRunId = currentRun.id;
|
||
appState.onelinerRunFilter = currentRun.run_status === "done" ? "done" : appState.onelinerRunFilter;
|
||
openActionModal({
|
||
title: currentRun.title || currentRun.plan?.goal || "主 Agent 执行结果",
|
||
description: currentRun.result?.execution_summary || currentRun.status_summary || "这是当前主 Agent 任务的执行结果。",
|
||
hideSubmit: true,
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "执行结果",
|
||
html: `<div class="sheet-html">${renderOneLinerExecutionPayloadHtml(currentRun.result)}</div>`
|
||
}
|
||
]
|
||
});
|
||
})
|
||
.catch(() => {
|
||
rememberAction("还没有可查看的结果", "当前主 Agent 任务还没有返回可展示的执行结果。", "orange");
|
||
renderAll();
|
||
});
|
||
}
|
||
|
||
async function openOneLinerRunContextAction(runId = "") {
|
||
const targetRunId = runId || appState.selectedOnelinerRunId || "";
|
||
if (!targetRunId) {
|
||
rememberAction("还没有可查看的运行", "当前还没有主 Agent 运行可继续查看。", "orange");
|
||
renderAll();
|
||
return null;
|
||
}
|
||
const currentProjectId = appState.selectedProjectId || getOneLinerProjectId();
|
||
if (!safeArray(appState.onelinerRuns).some((item) => item.id === targetRunId) && currentProjectId) {
|
||
await loadAgentControlSurfaces(currentProjectId);
|
||
}
|
||
appState.selectedOnelinerRunId = targetRunId;
|
||
const hydrated = await hydrateSelectedOneLinerRun();
|
||
const currentRun = hydrated || safeArray(appState.onelinerRuns).find((item) => item.id === targetRunId) || null;
|
||
if (currentRun?.run_status === "done") {
|
||
appState.onelinerRunFilter = "done";
|
||
}
|
||
openOneLinerPanel();
|
||
renderAll();
|
||
return currentRun;
|
||
}
|
||
|
||
function openConfirmOneLinerRunAction(runId = "") {
|
||
const run = safeArray(appState.onelinerRuns).find((item) => item.id === runId) || getCurrentOneLinerRun();
|
||
if (!run?.id) {
|
||
rememberAction("还没有可确认的任务", "当前没有主 Agent 待确认任务。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
const planSteps = safeArray(run.plan?.steps).slice(0, 5);
|
||
const previewAction = run.recommended_preview_action || null;
|
||
const tags = [
|
||
run.platform_label ? `<span class="tag blue">${escapeHtml(run.platform_label)}</span>` : "",
|
||
`<span class="tag">${escapeHtml(run.platform_scope === "all_platforms" ? "全平台" : "单平台")}</span>`,
|
||
`<span class="tag">${escapeHtml(onelinerIntentLabel(run.intent_key))}</span>`,
|
||
run.source_screen ? `<span class="tag">${escapeHtml(run.source_screen)}</span>` : ""
|
||
].filter(Boolean).join("");
|
||
const planHtml = `
|
||
<div class="task-item compact">
|
||
<h4>当前计划</h4>
|
||
<p>${escapeHtml(run.plan?.summary || run.summary || "主 Agent 会先按这张确认卡理解目标,再继续执行。")}</p>
|
||
<div class="task-meta">${tags}</div>
|
||
${planSteps.length ? `
|
||
<div class="list" style="margin-top:12px;">
|
||
${planSteps.map((step, index) => `
|
||
<div class="task-item compact">
|
||
<h4>步骤 ${escapeHtml(formatNumber(index + 1))}</h4>
|
||
<p>${escapeHtml(step)}</p>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
${previewAction ? `
|
||
<div class="task-item compact" style="margin-top:12px;">
|
||
<h4>预计落点</h4>
|
||
<p>${escapeHtml(previewAction.summary || "执行后会回到更合适的业务页面继续推进。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag green">${escapeHtml(previewAction.label || "回到对应页面")}</span>
|
||
${previewAction.screen ? `<span class="tag">${escapeHtml(previewAction.screen)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
`;
|
||
openActionModal({
|
||
title: "确认主 Agent 执行计划",
|
||
description: "确认后,主 Agent 会按当前计划进入执行流;你也可以先补一句执行说明。",
|
||
submitLabel: "确认执行",
|
||
fields: [
|
||
{
|
||
name: "plan",
|
||
type: "html",
|
||
label: "执行计划",
|
||
html: `<div class="sheet-html">${planHtml}</div>`
|
||
},
|
||
{
|
||
name: "reason",
|
||
type: "textarea",
|
||
label: "补充说明",
|
||
rows: 3,
|
||
placeholder: "可选:比如优先做抖音,或者先给我一版更保守的执行建议。",
|
||
value: ""
|
||
}
|
||
],
|
||
onSubmit: async (values) => {
|
||
const payload = await confirmOneLinerRun(run.id, values.reason || "user confirmed");
|
||
return {
|
||
keepOpen: false,
|
||
payload
|
||
};
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadPlatformAccount(platform, accountId, requestToken = 0) {
|
||
if (!accountId) return;
|
||
const normalizedPlatform = normalizePlatformValue(platform, getPreferredPlatform());
|
||
const token = requestToken || ((appState.selectedAccountRequestToken || 0) + 1);
|
||
if (!requestToken) {
|
||
appState.selectedAccountRequestToken = token;
|
||
}
|
||
appState.selectedAccountId = accountId;
|
||
setCurrentPlatform(normalizedPlatform);
|
||
if (!isWorkbenchPlatform(normalizedPlatform)) {
|
||
if (token !== appState.selectedAccountRequestToken) {
|
||
return false;
|
||
}
|
||
appState.selectedWorkspace = null;
|
||
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
|
||
appState.snapshots = [];
|
||
appState.selectedSnapshotId = "";
|
||
appState.selectedSnapshotDetail = null;
|
||
appState.creatorFields = null;
|
||
appState.analysisReports = [];
|
||
appState.similarSearchDetail = null;
|
||
return true;
|
||
}
|
||
const workspacePath = getWorkbenchRoute(normalizedPlatform, "workspace", accountId);
|
||
if (!workspacePath) {
|
||
if (token !== appState.selectedAccountRequestToken) {
|
||
return false;
|
||
}
|
||
appState.selectedWorkspace = null;
|
||
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
|
||
appState.snapshots = [];
|
||
appState.selectedSnapshotId = "";
|
||
appState.selectedSnapshotDetail = null;
|
||
appState.creatorFields = null;
|
||
appState.analysisReports = [];
|
||
appState.similarSearchDetail = null;
|
||
return true;
|
||
}
|
||
const videosPath = getWorkbenchRoute(normalizedPlatform, "videos", accountId);
|
||
try {
|
||
const [workspace, videos, snapshotsPayload, analysisReportsPayload] = await Promise.all([
|
||
storyforgeFetch(workspacePath),
|
||
videosPath
|
||
? storyforgeFetch(videosPath).catch(() => ({
|
||
items: [],
|
||
meta: {},
|
||
top_scored_video_ids: [],
|
||
latest_video_ids: [],
|
||
high_score_threshold: 60
|
||
}))
|
||
: Promise.resolve({
|
||
items: [],
|
||
meta: {},
|
||
top_scored_video_ids: [],
|
||
latest_video_ids: [],
|
||
high_score_threshold: 60
|
||
}),
|
||
normalizedPlatform === "douyin"
|
||
? storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/snapshots`).catch(() => [])
|
||
: Promise.resolve([]),
|
||
normalizedPlatform === "douyin"
|
||
? storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/analysis-reports`).catch(() => [])
|
||
: Promise.resolve([])
|
||
]);
|
||
if (token !== appState.selectedAccountRequestToken) {
|
||
return false;
|
||
}
|
||
appState.selectedWorkspace = workspace;
|
||
appState.selectedVideos = videos;
|
||
if (normalizedPlatform === "douyin") {
|
||
appState.snapshots = safeArray(snapshotsPayload?.items || snapshotsPayload);
|
||
appState.creatorFields = hasCreatorCenterSnapshot(appState.snapshots)
|
||
? await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/creator-fields`).catch(() => null)
|
||
: null;
|
||
appState.analysisReports = safeArray(analysisReportsPayload?.items || analysisReportsPayload);
|
||
const nextSnapshotId = appState.snapshots.find((item) => item.id === appState.selectedSnapshotId)?.id || appState.snapshots[0]?.id || "";
|
||
appState.selectedSnapshotId = nextSnapshotId;
|
||
appState.selectedSnapshotDetail = nextSnapshotId
|
||
? await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/snapshots/${encodeURIComponent(nextSnapshotId)}`).catch(() => null)
|
||
: null;
|
||
} else {
|
||
appState.snapshots = [];
|
||
appState.selectedSnapshotId = "";
|
||
appState.selectedSnapshotDetail = null;
|
||
appState.creatorFields = null;
|
||
appState.analysisReports = [];
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
if (token !== appState.selectedAccountRequestToken) {
|
||
return false;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function bootstrap() {
|
||
renderAll();
|
||
const backendMismatch = hasSessionBackendMismatch();
|
||
if (!appState.session || backendMismatch) {
|
||
setBusy(true, backendMismatch ? "正在切换到当前工作区后端..." : "正在自动连接后端...");
|
||
try {
|
||
await ensureAutoSession({ force: backendMismatch });
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
if (!appState.session) {
|
||
renderAuthUi();
|
||
return;
|
||
}
|
||
setBusy(true, "正在同步工作区...");
|
||
try {
|
||
appState.me = await storyforgeFetch("/v2/me");
|
||
if (appState.me.approval_status !== "approved" && appState.me.role !== "super_admin") {
|
||
appState.dashboard = null;
|
||
appState.accounts = [];
|
||
appState.contentSources = [];
|
||
appState.trackingAccounts = [];
|
||
appState.trackingDigest = null;
|
||
appState.trackingCursorMap = {};
|
||
appState.documents = [];
|
||
renderAll();
|
||
return;
|
||
}
|
||
appState.backendCapabilities = await loadBackendCapabilities(appState.session.backendUrl).catch(() => null);
|
||
const dashboard = await storyforgeFetch("/v2/me/dashboard");
|
||
appState.dashboard = dashboard;
|
||
const runtimePlatforms = getRuntimePlatformValues();
|
||
const preferredPlatform = getCurrentPlatformValue();
|
||
setCurrentPlatform(preferredPlatform);
|
||
const [contentSources, platformPayloads, reviews, integrationHealth, localModelCatalog, liveRecorderSourcesPayload] = await Promise.all([
|
||
storyforgeFetch("/v2/content-sources").catch(() => []),
|
||
Promise.all(runtimePlatforms.map(async (platform) => {
|
||
const accountListPath = getWorkbenchRoute(platform, "accounts");
|
||
const trackingAccountsPath = getWorkbenchRoute(platform, "trackingAccounts");
|
||
const trackingDigestPath = getWorkbenchRoute(platform, "trackingDigest");
|
||
const accounts = accountListPath
|
||
? await storyforgeFetch(accountListPath).catch(() => [])
|
||
: [];
|
||
const trackingAccountsPayload = trackingAccountsPath
|
||
? await storyforgeFetch(trackingAccountsPath).catch(() => ({ items: [], cursor_last_seen_at: "" }))
|
||
: { items: [], cursor_last_seen_at: "" };
|
||
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
|
||
const trackingDigest = trackingDigestPath
|
||
? await storyforgeFetch(`${trackingDigestPath}?since=${encodeURIComponent(trackingCursorLastSeenAt || getTrackingSinceIso(platform))}&limit=24`).catch(() => ({
|
||
items: [],
|
||
tracked_accounts: [],
|
||
cursor_last_seen_at: trackingCursorLastSeenAt
|
||
}))
|
||
: {
|
||
items: [],
|
||
tracked_accounts: [],
|
||
cursor_last_seen_at: trackingCursorLastSeenAt
|
||
};
|
||
return {
|
||
platform,
|
||
accounts: safeArray(accounts).map((item) => ({
|
||
...item,
|
||
platform: normalizePlatformValue(item?.platform || platform, platform)
|
||
})),
|
||
trackingAccountsPayload,
|
||
trackingDigest
|
||
};
|
||
})),
|
||
storyforgeFetch("/v2/reviews").catch(() => []),
|
||
storyforgeFetch("/v2/integrations/health").catch(() => null),
|
||
storyforgeFetch("/v2/integrations/local-models").catch(() => null),
|
||
storyforgeFetch("/v2/live-recorder/sources").catch(() => ({ items: [] }))
|
||
]);
|
||
const liveRecorderIntegration = integrationHealth?.live_recorder || null;
|
||
const canLoadLiveRecorderRuntime = Boolean(liveRecorderIntegration?.reachable);
|
||
const [liveRecorderStatus, liveRecorderFilesPayload, liveRecorderHealth] = await Promise.all([
|
||
canLoadLiveRecorderRuntime ? storyforgeFetch("/v2/live-recorder/status").catch(() => null) : Promise.resolve(null),
|
||
canLoadLiveRecorderRuntime ? storyforgeFetch("/v2/live-recorder/files?limit=16").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }),
|
||
canLoadLiveRecorderRuntime ? storyforgeFetch("/v2/live-recorder/health").catch(() => null) : Promise.resolve(null)
|
||
]);
|
||
const mergedAccounts = safeArray(platformPayloads)
|
||
.flatMap((entry) => safeArray(entry.accounts))
|
||
.sort((a, b) => {
|
||
const platformCompare = platformLabel(getAccountPlatform(a)).localeCompare(platformLabel(getAccountPlatform(b)), "zh-Hans-CN");
|
||
if (platformCompare !== 0) return platformCompare;
|
||
return getAccountName(a).localeCompare(getAccountName(b), "zh-Hans-CN");
|
||
});
|
||
const accountIndex = new Map(mergedAccounts.map((item) => [item.id, item]));
|
||
const assistantNameMap = buildAssistantNameMap(dashboard.assistants);
|
||
const trackingCursorMap = Object.fromEntries(
|
||
safeArray(platformPayloads)
|
||
.map((entry) => [entry.platform, entry.trackingAccountsPayload?.cursor_last_seen_at || ""])
|
||
.filter(([, value]) => value)
|
||
);
|
||
const currentCursor = trackingCursorMap[preferredPlatform] || pickLatestIso(Object.values(trackingCursorMap)) || "";
|
||
if (currentCursor) {
|
||
setLastSeenAt(currentCursor);
|
||
}
|
||
appState.contentSources = safeArray(contentSources);
|
||
appState.accounts = mergedAccounts;
|
||
appState.trackingCursorMap = trackingCursorMap;
|
||
appState.trackingAccounts = safeArray(platformPayloads).flatMap((entry) =>
|
||
enrichTrackingAccounts(
|
||
entry.trackingAccountsPayload?.items || entry.trackingAccountsPayload,
|
||
accountIndex,
|
||
assistantNameMap,
|
||
entry.platform
|
||
)
|
||
);
|
||
appState.trackingDigest = {
|
||
cursor_last_seen_at: currentCursor,
|
||
items: safeArray(platformPayloads).flatMap((entry) => enrichTrackingDigestItems(entry.trackingDigest?.items, accountIndex, entry.platform)),
|
||
tracked_accounts: safeArray(platformPayloads).flatMap((entry) =>
|
||
safeArray(entry.trackingDigest?.tracked_accounts).map((item) => ({
|
||
...item,
|
||
platform: normalizePlatformValue(item.platform || entry.platform, entry.platform)
|
||
}))
|
||
)
|
||
};
|
||
appState.reviews = safeArray(reviews);
|
||
appState.liveRecorderSources = safeArray(liveRecorderSourcesPayload?.items || liveRecorderSourcesPayload);
|
||
appState.liveRecorderStatus = liveRecorderStatus;
|
||
appState.liveRecorderFiles = safeArray(liveRecorderFilesPayload?.items || liveRecorderFilesPayload);
|
||
appState.integrationHealth = integrationHealth;
|
||
appState.localModelCatalog = localModelCatalog;
|
||
appState.liveRecorderHealth = liveRecorderHealth;
|
||
appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases);
|
||
appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || "";
|
||
await loadStorageStatus(appState.selectedProjectId || "");
|
||
await loadAgentControlSurfaces(appState.selectedProjectId || "");
|
||
if (appState.selectedOnelinerSessionId) {
|
||
await loadOneLinerMessages(appState.selectedOnelinerSessionId);
|
||
} else {
|
||
appState.onelinerMessages = [];
|
||
}
|
||
const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId);
|
||
appState.selectedAssistantId = selectedAssistantExists ? appState.selectedAssistantId : (dashboard.assistants?.[0]?.id || "");
|
||
const platformAccounts = getAccountsForPlatform(preferredPlatform);
|
||
const selectedAccountExists = platformAccounts.some((item) => item.id === appState.selectedAccountId);
|
||
const nextAccountId = selectedAccountExists ? appState.selectedAccountId : platformAccounts[0]?.id || appState.accounts[0]?.id || "";
|
||
if (nextAccountId) {
|
||
const nextAccount = appState.accounts.find((item) => item.id === nextAccountId) || null;
|
||
await loadPlatformAccount(getAccountPlatform(nextAccount), nextAccountId);
|
||
} else {
|
||
appState.selectedAccountId = "";
|
||
appState.selectedWorkspace = null;
|
||
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
|
||
}
|
||
} catch (error) {
|
||
appState.message = error.message;
|
||
if (
|
||
String(error.message || "").includes("401")
|
||
|| String(error.message || "").includes("Not authenticated")
|
||
|| String(error.message || "").includes("Invalid token")
|
||
) {
|
||
persistSession(null);
|
||
appState.autoConnectAttempted = false;
|
||
}
|
||
} finally {
|
||
appState.recoveryRecords = getRecoveryRecords();
|
||
setBusy(false, "");
|
||
renderAll();
|
||
}
|
||
}
|
||
|
||
async function markTrackingDigestRead() {
|
||
const platform = getCurrentPlatformValue();
|
||
const trackingCursorPath = getWorkbenchRoute(platform, "trackingCursor");
|
||
const nextSeenAt = new Date().toISOString();
|
||
await storyforgeFetch(trackingCursorPath, {
|
||
method: "POST",
|
||
body: { last_seen_at: nextSeenAt }
|
||
});
|
||
appState.trackingCursorMap = {
|
||
...(appState.trackingCursorMap || {}),
|
||
[platform]: nextSeenAt
|
||
};
|
||
if (appState.trackingDigest) {
|
||
appState.trackingDigest = {
|
||
...appState.trackingDigest,
|
||
items: safeArray(appState.trackingDigest.items).filter((item) => item.platform !== platform),
|
||
cursor_last_seen_at: platform === getCurrentPlatformValue()
|
||
? nextSeenAt
|
||
: (appState.trackingDigest.cursor_last_seen_at || "")
|
||
};
|
||
}
|
||
setLastSeenAt(nextSeenAt);
|
||
}
|
||
|
||
async function refreshTrackingAccountsAction() {
|
||
const platform = getCurrentPlatformValue();
|
||
const trackingRefreshPath = getWorkbenchRoute(platform, "trackingRefresh");
|
||
setBusy(true, "正在同步跟踪账号...");
|
||
try {
|
||
const payload = await storyforgeFetch(trackingRefreshPath, {
|
||
method: "POST"
|
||
});
|
||
const refreshNotice = summarizeTrackingRefreshPayload(payload, platform, "batch");
|
||
rememberTrackingRefreshNotice(refreshNotice);
|
||
rememberAction(
|
||
refreshNotice?.title || "跟踪已同步",
|
||
refreshNotice?.summary || `已刷新 ${formatNumber(payload.refreshed || 0)} 个账号${payload.failed ? `,失败 ${formatNumber(payload.failed)} 个` : ""}。`,
|
||
refreshNotice?.tone || (payload.failed ? "orange" : "green"),
|
||
payload
|
||
);
|
||
await bootstrap();
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
|
||
async function refreshTrackedAccountAction(trackedAccountId) {
|
||
if (!trackedAccountId) {
|
||
throw new Error("trackedAccountId is required");
|
||
}
|
||
const trackedItem = getTrackingAccounts().find((item) => item.tracked_account_id === trackedAccountId);
|
||
const platform = trackedItem?.platform || getCurrentPlatformValue();
|
||
const trackingRefreshPath = getWorkbenchRoute(platform, "trackingAccountRefresh", trackedAccountId);
|
||
setBusy(true, "正在同步该跟踪账号...");
|
||
try {
|
||
const payload = await storyforgeFetch(trackingRefreshPath, {
|
||
method: "POST"
|
||
});
|
||
const refreshNotice = summarizeTrackingRefreshPayload(payload, platform, "single");
|
||
rememberTrackingRefreshNotice(refreshNotice);
|
||
const success = payload.success !== false;
|
||
rememberAction(
|
||
refreshNotice?.title || (success ? "单账号已同步" : "单账号刷新失败"),
|
||
refreshNotice?.summary || (success
|
||
? `已刷新「${payload.account?.nickname || trackedAccountId}」的最新作品。`
|
||
: `暂时无法刷新「${payload.account?.nickname || trackedAccountId}」:${payload.message || "请稍后重试"}`),
|
||
refreshNotice?.tone || (success ? (safeArray(payload.sync_errors).length ? "orange" : "green") : "orange"),
|
||
payload
|
||
);
|
||
await bootstrap();
|
||
if (payload.sync_job_id) {
|
||
openJobDetailAction(payload.sync_job_id);
|
||
}
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
|
||
function getSelectedProject() {
|
||
const projects = safeArray(appState.dashboard?.projects);
|
||
return projects.find((item) => item.id === appState.selectedProjectId) || projects[0] || null;
|
||
}
|
||
|
||
function isSuperAdmin() {
|
||
return appState.me?.role === "super_admin";
|
||
}
|
||
|
||
function getOneLinerProjectId() {
|
||
return getSelectedProject()?.id || appState.selectedProjectId || safeArray(appState.dashboard?.projects)[0]?.id || "";
|
||
}
|
||
|
||
function getCurrentOneLinerSession() {
|
||
const sessions = safeArray(appState.onelinerSessions);
|
||
return sessions.find((item) => item.id === appState.selectedOnelinerSessionId) || sessions[0] || null;
|
||
}
|
||
|
||
function choosePreferredOneLinerRunId(items, currentId = "") {
|
||
const runs = safeArray(items);
|
||
if (currentId && runs.some((item) => item.id === currentId)) {
|
||
return currentId;
|
||
}
|
||
return runs.find((item) => item.run_status === "needs_confirmation")?.id || runs[0]?.id || "";
|
||
}
|
||
|
||
function getCurrentOneLinerRun() {
|
||
const runs = safeArray(appState.onelinerRuns);
|
||
const preferredId = choosePreferredOneLinerRunId(runs, appState.selectedOnelinerRunId || "");
|
||
return runs.find((item) => item.id === preferredId) || null;
|
||
}
|
||
|
||
function onelinerIntentLabel(value) {
|
||
return ONELINER_INTENT_LABELS[value] || value || "自定义任务";
|
||
}
|
||
|
||
function getProjectKnowledgeBases(projectId) {
|
||
return safeArray(appState.dashboard?.knowledge_bases).filter((item) => item.project_id === projectId);
|
||
}
|
||
|
||
function getProjectAssistants(projectId) {
|
||
return safeArray(appState.dashboard?.assistants).filter((item) => item.project_id === projectId);
|
||
}
|
||
|
||
function getSelectedAssistant() {
|
||
const assistants = safeArray(appState.dashboard?.assistants);
|
||
return assistants.find((item) => item.id === appState.selectedAssistantId) || assistants[0] || null;
|
||
}
|
||
|
||
function getProjectOptions() {
|
||
return safeArray(appState.dashboard?.projects).map((project) => ({ value: project.id, label: project.name }));
|
||
}
|
||
|
||
function getAssistantOptions(projectId) {
|
||
return getProjectAssistants(projectId).map((assistant) => ({ value: assistant.id, label: assistant.name }));
|
||
}
|
||
|
||
function getKnowledgeBaseOptions(projectId) {
|
||
return getProjectKnowledgeBases(projectId).map((kb) => ({ value: kb.id, label: kb.name }));
|
||
}
|
||
|
||
function getModelOptions() {
|
||
return safeArray(appState.dashboard?.model_profiles).map((model) => ({ value: model.id, label: model.name }));
|
||
}
|
||
|
||
function getCurrentModelProfile() {
|
||
const models = safeArray(appState.dashboard?.model_profiles);
|
||
const currentId = appState.me?.preferred_analysis_model_id
|
||
|| models.find((item) => item.is_default)?.id
|
||
|| "";
|
||
return models.find((item) => item.id === currentId) || models.find((item) => item.is_default) || models[0] || null;
|
||
}
|
||
|
||
function getCompletedJobOptions() {
|
||
return safeArray(appState.dashboard?.recent_jobs)
|
||
.filter((item) => item.status === "completed")
|
||
.map((job) => ({
|
||
value: job.id,
|
||
label: `${job.title} · ${job.line_type || "analysis"}`
|
||
}));
|
||
}
|
||
|
||
function getProjectStats(projectId) {
|
||
const dashboard = appState.dashboard || {};
|
||
const knowledgeBases = safeArray(dashboard.knowledge_bases).filter((item) => item.project_id === projectId);
|
||
const assistants = safeArray(dashboard.assistants).filter((item) => item.project_id === projectId);
|
||
const jobs = safeArray(dashboard.recent_jobs).filter((item) => item.project_id === projectId);
|
||
const sources = safeArray(appState.contentSources).filter((item) => item.project_id === projectId);
|
||
return { knowledgeBases, assistants, jobs, sources };
|
||
}
|
||
|
||
function getProjectReviews(projectId) {
|
||
return safeArray(appState.reviews).filter((item) => item.project_id === projectId);
|
||
}
|
||
|
||
function getReviewById(reviewId) {
|
||
return safeArray(appState.reviews).find((item) => item.id === reviewId) || null;
|
||
}
|
||
|
||
function getContentSourcesForAccount(account) {
|
||
if (!account) return [];
|
||
const platform = getAccountPlatform(account);
|
||
const profileUrl = getAccountProfileUrl(account);
|
||
const handle = getAccountHandle(account);
|
||
const nickname = getAccountName(account);
|
||
return safeArray(appState.contentSources).filter((source) => {
|
||
const sourceUrl = String(source.source_url || "").trim();
|
||
const sourceHandle = String(source.handle || "").trim();
|
||
const title = String(source.title || "").trim();
|
||
const sourcePlatform = normalizePlatformValue(source.platform || "", platform);
|
||
return (
|
||
sourcePlatform === platform && (
|
||
(profileUrl && sourceUrl === profileUrl) ||
|
||
(handle && sourceHandle === handle) ||
|
||
(nickname && title.includes(nickname))
|
||
)
|
||
);
|
||
});
|
||
}
|
||
|
||
function getCurrentProjectSourcesForAccount(account, projectId) {
|
||
return getContentSourcesForAccount(account).filter((source) => source.project_id === projectId);
|
||
}
|
||
|
||
function getCurrentPlatformValue() {
|
||
const available = getRuntimePlatformValues();
|
||
const fallback = available[0] || "douyin";
|
||
const current = normalizePlatformValue(appState.currentPlatform, "");
|
||
if (current && available.includes(current)) return current;
|
||
return normalizePlatformValue(getPreferredPlatform(), fallback);
|
||
}
|
||
|
||
function getAccountsForPlatform(platform) {
|
||
const normalizedPlatform = normalizePlatformValue(platform, getCurrentPlatformValue());
|
||
return safeArray(appState.accounts).filter((item) => getAccountPlatform(item) === normalizedPlatform);
|
||
}
|
||
|
||
function parseIsoTime(value) {
|
||
const text = String(value || "").trim();
|
||
if (!text) return 0;
|
||
const timestamp = new Date(text).getTime();
|
||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||
}
|
||
|
||
function sortItemsByIsoDesc(items, fieldName) {
|
||
return items
|
||
.slice()
|
||
.sort((left, right) => parseIsoTime(right?.[fieldName]) - parseIsoTime(left?.[fieldName]));
|
||
}
|
||
|
||
function pickLatestIso(values) {
|
||
return values
|
||
.map((value) => String(value || "").trim())
|
||
.filter(Boolean)
|
||
.sort((left, right) => parseIsoTime(right) - parseIsoTime(left))[0] || "";
|
||
}
|
||
|
||
function createEmptyTrackingDigest(cursorLastSeenAt = "") {
|
||
return {
|
||
items: [],
|
||
tracked_accounts: [],
|
||
cursor_last_seen_at: cursorLastSeenAt
|
||
};
|
||
}
|
||
|
||
function getAssistantById(assistantId) {
|
||
if (!assistantId) return null;
|
||
return safeArray(appState.dashboard?.assistants).find((item) => item.id === assistantId) || null;
|
||
}
|
||
|
||
function getTrackedAccountDisplay(item) {
|
||
const account = item?.account
|
||
|| safeArray(appState.accounts).find((row) => row.id === item?.tracked_account_id)
|
||
|| null;
|
||
const assistant = getAssistantById(item?.assistant_id || "");
|
||
const platform = normalizePlatformValue(item?.platform || account?.platform || getCurrentPlatformValue(), getCurrentPlatformValue());
|
||
return {
|
||
...item,
|
||
account,
|
||
platform,
|
||
assistant_name: item?.assistant_name || assistant?.name || "",
|
||
note: item?.note || ""
|
||
};
|
||
}
|
||
|
||
function isTrackedAccount(accountId) {
|
||
return safeArray(appState.trackingAccounts).some((item) => item.tracked_account_id === accountId);
|
||
}
|
||
|
||
function getTrackingAccounts() {
|
||
return sortItemsByIsoDesc(
|
||
safeArray(appState.trackingAccounts).map((item) => getTrackedAccountDisplay(item)),
|
||
"updated_at"
|
||
);
|
||
}
|
||
|
||
function getTrackingAccountsForProject(projectId) {
|
||
if (!projectId) return getTrackingAccounts();
|
||
const assistantIds = new Set(getProjectAssistants(projectId).map((item) => item.id));
|
||
const scoped = getTrackingAccounts().filter((item) => item.assistant_id && assistantIds.has(item.assistant_id));
|
||
return scoped.length ? scoped : getTrackingAccounts();
|
||
}
|
||
|
||
function dashboardTabLabel(value) {
|
||
return ({
|
||
project_progress: "项目进度",
|
||
focus_accounts: "重点账号 / 对标",
|
||
production_jobs: "生产任务"
|
||
})[value] || "项目进度";
|
||
}
|
||
|
||
function getDashboardActionSourceLabel() {
|
||
return appState.onelinerProfile ? "主 Agent 优先推荐" : "规则推荐";
|
||
}
|
||
|
||
function hasCreatorCenterSnapshot(items) {
|
||
return safeArray(items).some((item) => String(item?.snapshot_type || "").toLowerCase() === "creator_center");
|
||
}
|
||
|
||
function getDashboardProjectProgressSummary(project, stats, trackedAccounts) {
|
||
const total = 5;
|
||
const completed = [
|
||
Boolean(project),
|
||
stats.assistants.length > 0,
|
||
stats.sources.length > 0 || appState.accounts.length > 0,
|
||
trackedAccounts.length > 0,
|
||
stats.jobs.length > 0
|
||
].filter(Boolean).length;
|
||
const failedJobs = stats.jobs.filter((item) => item.status === "failed").length;
|
||
let nextStep = "先创建当前项目";
|
||
if (project && !stats.assistants.length) {
|
||
nextStep = "补第一个 Agent";
|
||
} else if (project && !stats.sources.length && !appState.accounts.length) {
|
||
nextStep = "导入主页或作品";
|
||
} else if (project && !trackedAccounts.length) {
|
||
nextStep = "加一个重点跟踪";
|
||
} else if (project && !stats.jobs.length) {
|
||
nextStep = "发起第一条生产任务";
|
||
} else if (project) {
|
||
nextStep = "继续推进当前主流程";
|
||
}
|
||
const risk = failedJobs
|
||
? `${failedJobs} 条任务失败待处理`
|
||
: (!trackedAccounts.length && project)
|
||
? "还没有重点账号跟踪"
|
||
: "当前主流程没有明显阻塞";
|
||
return { total, completed, nextStep, risk };
|
||
}
|
||
|
||
function buildDashboardOverviewTabs(project, stats, trackedAccounts) {
|
||
const progress = getDashboardProjectProgressSummary(project, stats, trackedAccounts);
|
||
const pendingJobs = stats.jobs.filter((item) => item.status !== "completed").length;
|
||
return [
|
||
{
|
||
key: "project_progress",
|
||
label: "项目进度",
|
||
value: `${progress.completed} / ${progress.total}`,
|
||
hint: progress.nextStep,
|
||
active: appState.dashboardOverviewTab === "project_progress"
|
||
},
|
||
{
|
||
key: "focus_accounts",
|
||
label: "重点账号 / 对标",
|
||
value: formatNumber(trackedAccounts.length || appState.accounts.length),
|
||
hint: trackedAccounts.length ? "优先看变化最多的对象" : "等待接入重点对象",
|
||
active: appState.dashboardOverviewTab === "focus_accounts"
|
||
},
|
||
{
|
||
key: "production_jobs",
|
||
label: "生产任务",
|
||
value: formatNumber(stats.jobs.length),
|
||
hint: pendingJobs ? `${formatNumber(pendingJobs)} 条待推进` : "可发起下一条任务",
|
||
active: appState.dashboardOverviewTab === "production_jobs"
|
||
}
|
||
];
|
||
}
|
||
|
||
function renderDashboardProjectProgressBody(project, stats, trackedAccounts) {
|
||
const progress = getDashboardProjectProgressSummary(project, stats, trackedAccounts);
|
||
return `
|
||
<div class="dashboard-overview-detail">
|
||
<div class="mini-grid">
|
||
<div class="mini-card">
|
||
<small>当前项目</small>
|
||
<strong>${escapeHtml(project?.name || "还没有项目")}</strong>
|
||
<span>${escapeHtml(project?.description || "先选定项目,首页动作才会真正收敛。")}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>主流程进度</small>
|
||
<strong>${escapeHtml(`${progress.completed} / ${progress.total}`)}</strong>
|
||
<span>${escapeHtml(progress.nextStep)}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>阶段风险</small>
|
||
<strong>${escapeHtml(progress.risk)}</strong>
|
||
<span>${escapeHtml(stats.jobs.length ? `当前项目任务 ${formatNumber(stats.jobs.length)} 条` : "还没有生产任务")}</span>
|
||
</div>
|
||
</div>
|
||
<div class="task-item compact dashboard-overview-note">
|
||
<h4>下一步建议</h4>
|
||
<p>${escapeHtml(project ? `当前项目建议先「${progress.nextStep}」,做完后再回到首页看下一条动作。` : "先创建项目或切换到已有项目,然后首页动作区会自动切到该项目。")}</p>
|
||
<div class="task-meta">
|
||
${actionTag(project ? "切换项目" : "去建项目", project ? "open-dashboard-project-switcher" : "goto-intake")}
|
||
${actionTag("去生产中心", "goto-production")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDashboardFocusAccountsBody(project, trackedAccounts) {
|
||
const fallbackAccounts = safeArray(appState.accounts).slice(0, 3).map((item) => ({
|
||
platform: getAccountPlatform(item),
|
||
assistant_name: "",
|
||
account: item
|
||
}));
|
||
const items = (trackedAccounts.length ? trackedAccounts : fallbackAccounts).slice(0, 3);
|
||
if (!items.length) {
|
||
return `
|
||
<div class="task-item compact dashboard-overview-empty">
|
||
<h4>还没有重点账号 / 对标</h4>
|
||
<p>${escapeHtml(project ? "先导入主页或把一个重点账号加入跟踪,首页才会持续给出更聪明的动作推荐。" : "先创建项目,再导入第一个重点账号。")}</p>
|
||
<div class="task-meta">
|
||
${actionTag("去找对标", "goto-discovery")}
|
||
${actionTag("去跟踪账号", "goto-tracking")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div class="three-col dashboard-overview-grid">
|
||
${items.map((item) => {
|
||
const account = item.account || item;
|
||
const accountId = account?.id || item.tracked_account_id || "";
|
||
return `
|
||
<div class="entity-card pad dashboard-overview-card">
|
||
<div class="entity-cell">
|
||
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
|
||
<div>
|
||
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
|
||
<div class="cell-desc">${escapeHtml(platformLabel(item.platform || getAccountPlatform(account)))} · ${escapeHtml(item.assistant_name || account.signature || "重点关注对象")}</div>
|
||
</div>
|
||
</div>
|
||
<div class="entity-meta">
|
||
<span class="tag blue">${escapeHtml(account.video_summary?.count ? `作品 ${formatNumber(account.video_summary.count)}` : "重点对象")}</span>
|
||
${account.sync_status ? `<span class="tag">${escapeHtml(account.sync_status)}</span>` : ""}
|
||
${accountId ? actionTag("查看详情", "select-account", `data-account-id="${escapeHtml(accountId)}"`) : ""}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDashboardProductionJobsBody(stats) {
|
||
const jobs = sortItemsByIsoDesc(stats.jobs, "updated_at").slice(0, 4);
|
||
if (!jobs.length) {
|
||
return `
|
||
<div class="task-item compact dashboard-overview-empty">
|
||
<h4>当前项目还没有生产任务</h4>
|
||
<p>先补完项目和对标,再从首页动作区或生产中心发起第一条任务。</p>
|
||
<div class="task-meta">
|
||
${actionTag("去生产中心", "goto-production")}
|
||
${actionTag("去找对标", "goto-discovery")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div class="list dashboard-overview-list">
|
||
${jobs.map((job) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(job.title || job.id)}</h4>
|
||
<p>${escapeHtml(brief(job.error || job.summary || `最近更新于 ${formatDateTime(job.updated_at || job.created_at)}`, 120))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${statusTone(job.status)}">${escapeHtml(job.status || "-")}</span>
|
||
${job.line_type ? `<span class="tag">${escapeHtml(job.line_type)}</span>` : ""}
|
||
${actionTag("查看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)}
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDashboardOverviewBody(tab, context) {
|
||
if (tab === "focus_accounts") {
|
||
return renderDashboardFocusAccountsBody(context.project, context.trackedAccounts);
|
||
}
|
||
if (tab === "production_jobs") {
|
||
return renderDashboardProductionJobsBody(context.stats);
|
||
}
|
||
return renderDashboardProjectProgressBody(context.project, context.stats, context.trackedAccounts);
|
||
}
|
||
|
||
function buildDashboardHomeModel() {
|
||
const project = getSelectedProject();
|
||
const emptyStats = { knowledgeBases: [], assistants: [], jobs: [], sources: [] };
|
||
const stats = project ? getProjectStats(project.id) : emptyStats;
|
||
const trackedAccounts = project ? getTrackingAccountsForProject(project.id) : getTrackingAccounts();
|
||
const dashboardModule = window.StoryForgeDashboardHome;
|
||
const summaryTabs = buildDashboardOverviewTabs(project, stats, trackedAccounts);
|
||
const activeTabLabel = dashboardTabLabel(appState.dashboardOverviewTab);
|
||
const baseModel = dashboardModule?.createDashboardHomeModel
|
||
? dashboardModule.createDashboardHomeModel({
|
||
workspaceLabel: appState.me?.display_name || appState.me?.username || "当前工作区",
|
||
currentProjectName: project?.name || "还没有项目",
|
||
trackedAccountsCount: trackedAccounts.length || appState.accounts.length,
|
||
assistantCount: stats.assistants.length,
|
||
jobCount: stats.jobs.length,
|
||
hasProject: Boolean(project),
|
||
actionSourceLabel: getDashboardActionSourceLabel(),
|
||
dashboardOverviewTab: appState.dashboardOverviewTab,
|
||
summaryTabs,
|
||
activeTabLabel,
|
||
contextLinks: [
|
||
{ label: "账号", value: formatNumber(trackedAccounts.length || appState.accounts.length), action: "goto-owned" },
|
||
{ label: "任务", value: formatNumber(stats.jobs.length), action: "goto-production" },
|
||
{ label: "Agent", value: formatNumber(stats.assistants.length), action: "goto-playbook" }
|
||
]
|
||
})
|
||
: {
|
||
workspaceLabel: appState.me?.display_name || appState.me?.username || "当前工作区",
|
||
currentProjectName: project?.name || "还没有项目",
|
||
actionSourceLabel: getDashboardActionSourceLabel(),
|
||
contextLinks: [
|
||
{ label: "账号", value: formatNumber(trackedAccounts.length || appState.accounts.length), action: "goto-owned" },
|
||
{ label: "任务", value: formatNumber(stats.jobs.length), action: "goto-production" },
|
||
{ label: "Agent", value: formatNumber(stats.assistants.length), action: "goto-playbook" }
|
||
],
|
||
primaryAction: {
|
||
title: project ? "继续推进当前项目主流程" : "先创建或切换到一个项目",
|
||
reason: project ? "从首页动作区进入当前最该做的事。" : "首页动作和概览都跟随当前项目。",
|
||
badges: ["默认动作"],
|
||
goAction: project ? "goto-production" : "goto-intake",
|
||
goLabel: project ? "去处理" : "去项目",
|
||
agentLabel: "交给主 Agent"
|
||
},
|
||
secondaryActions: [],
|
||
summaryTabs,
|
||
activeTabLabel
|
||
};
|
||
return {
|
||
...baseModel,
|
||
summaryTabs,
|
||
activeTabLabel,
|
||
overviewBodyHtml: renderDashboardOverviewBody(appState.dashboardOverviewTab, { project, stats, trackedAccounts })
|
||
};
|
||
}
|
||
|
||
async function applySelectedProject(projectId = "") {
|
||
appState.selectedProjectId = projectId || "";
|
||
setBusy(true, "正在切换项目视图...");
|
||
try {
|
||
await loadStorageStatus(appState.selectedProjectId || "");
|
||
await loadAgentControlSurfaces(appState.selectedProjectId || "");
|
||
if (appState.selectedOnelinerSessionId) {
|
||
await loadOneLinerMessages(appState.selectedOnelinerSessionId);
|
||
}
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
rememberAction("当前项目已切换", `已切换到「${getSelectedProject()?.name || "所选项目"}」,你现在看到的首页、Agent 和任务都会跟随更新。`, "green");
|
||
renderAll();
|
||
}
|
||
|
||
function openDashboardProjectSwitcher() {
|
||
const options = getProjectOptions();
|
||
if (!options.length) {
|
||
rememberAction("还没有项目", "先创建一个项目,再让首页跟着当前项目切换。", "orange");
|
||
setScreen("intake");
|
||
return;
|
||
}
|
||
const selectedProject = getSelectedProject();
|
||
const projects = safeArray(appState.dashboard?.projects);
|
||
const isMobileViewport = typeof window !== "undefined" && window.matchMedia?.("(max-width: 760px)")?.matches;
|
||
const projectCards = projects.map((project) => {
|
||
const stats = getProjectStats(project.id);
|
||
const reviewCount = getProjectReviews(project.id).length;
|
||
const isActive = project.id === selectedProject?.id;
|
||
return `
|
||
<button
|
||
class="task-item compact project-choice-card ${isActive ? "active" : ""}"
|
||
type="button"
|
||
data-project-choice="${escapeHtml(project.id)}"
|
||
style="${isActive ? "border-color:rgba(59, 130, 246, 0.32); background:linear-gradient(180deg, rgba(239, 246, 255, 0.98) 0%, rgba(255,255,255,0.98) 100%);" : ""}"
|
||
>
|
||
<h4>${escapeHtml(project.name || "未命名项目")}</h4>
|
||
<p>${escapeHtml(project.description || "还没有补项目说明,适合先切过去继续完善。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${isActive ? "green" : "blue"}">${escapeHtml(isActive ? "当前项目" : "可切换")}</span>
|
||
<span class="tag">最近任务 ${escapeHtml(formatNumber(stats.jobs.length))}</span>
|
||
<span class="tag">Agent ${escapeHtml(formatNumber(stats.assistants.length))}</span>
|
||
<span class="tag">复盘 ${escapeHtml(formatNumber(reviewCount))}</span>
|
||
</div>
|
||
</button>
|
||
`;
|
||
}).join("");
|
||
openActionModal({
|
||
title: "切换当前项目",
|
||
description: "首页上下文、今日动作和项目概览都会跟着当前项目一起切换。手机端会优先让你先扫一眼当前项目和最近任务。",
|
||
submitLabel: "切换项目",
|
||
fields: [
|
||
{
|
||
name: "projectSummary",
|
||
label: "项目速览",
|
||
type: "html",
|
||
html: `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(selectedProject?.name || "当前还没有项目")}</h4>
|
||
<p>${escapeHtml(selectedProject?.description || "切换项目后,首页、OneLiner 和工作台会一起同步到对应上下文。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag green">当前项目</span>
|
||
<span class="tag">最近任务 ${escapeHtml(formatNumber(getProjectStats(selectedProject?.id || "").jobs.length))}</span>
|
||
<span class="tag">切换到这个项目后会同步总台、Agent 和生产中心</span>
|
||
</div>
|
||
</div>
|
||
<div class="list" style="margin-top:12px;">
|
||
<div class="task-item compact">
|
||
<h4>最近任务</h4>
|
||
<p>优先切到你现在真正要推进的项目,再从首页和主 Agent 继续往下走。</p>
|
||
</div>
|
||
${projectCards}
|
||
</div>
|
||
`
|
||
},
|
||
{ name: "projectId", label: "当前项目", type: "select", value: getSelectedProject()?.id || "", options, hidden: isMobileViewport }
|
||
],
|
||
onOpen: ({ fields, submit }) => {
|
||
const select = fields.querySelector('[data-action-field="projectId"]');
|
||
if (submit && isMobileViewport) {
|
||
submit.hidden = true;
|
||
}
|
||
fields.querySelectorAll("[data-project-choice]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
const nextProjectId = button.dataset.projectChoice || "";
|
||
if (select) {
|
||
select.value = nextProjectId;
|
||
}
|
||
fields.querySelectorAll("[data-project-choice]").forEach((item) => item.classList.remove("active"));
|
||
button.classList.add("active");
|
||
if (isMobileViewport && nextProjectId) {
|
||
closeActionModal();
|
||
await applySelectedProject(nextProjectId);
|
||
}
|
||
});
|
||
});
|
||
},
|
||
onSubmit: async (payload) => {
|
||
await applySelectedProject(payload.projectId || "");
|
||
}
|
||
});
|
||
}
|
||
|
||
function openDashboardActionReasonAction(index) {
|
||
const model = buildDashboardHomeModel();
|
||
const actions = [model.primaryAction, ...safeArray(model.secondaryActions)];
|
||
const action = actions[Number(index)] || actions[0];
|
||
if (!action) return;
|
||
appState.dashboardActionReason = {
|
||
title: action.title,
|
||
reason: action.reason,
|
||
sourceLabel: model.actionSourceLabel,
|
||
badges: safeArray(action.badges),
|
||
goLabel: action.goLabel || "去处理"
|
||
};
|
||
openActionModal({
|
||
title: "动作原因",
|
||
description: "首页只放最短判断,这里再把原因展开一层。",
|
||
hideSubmit: true,
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "动作详情",
|
||
html: `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(appState.dashboardActionReason.title)}</h4>
|
||
<p>${escapeHtml(appState.dashboardActionReason.reason)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(appState.dashboardActionReason.sourceLabel)}</span>
|
||
${appState.dashboardActionReason.badges.map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join("")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
function getTrackingDigestItems(limit = 6, options = {}) {
|
||
const targetPlatform = normalizePlatformValue(options.platform || "", "");
|
||
const fallbackPlatform = targetPlatform || getCurrentPlatformValue();
|
||
return safeArray(appState.trackingDigest?.items)
|
||
.map((item) => {
|
||
const tracked = getTrackedAccountDisplay(item);
|
||
const summary = item?.summary || item?.summary_text || item?.video?.description || item?.video?.title || item?.description || "";
|
||
const video = item?.video || {};
|
||
const isHighValue = item?.is_high_value != null
|
||
? Boolean(item.is_high_value)
|
||
: Number(video?.score?.performance_score || 0) >= 60;
|
||
return {
|
||
...item,
|
||
account: item?.account || tracked.account,
|
||
platform: tracked.platform || fallbackPlatform,
|
||
assistant_name: item?.assistant_name || tracked.assistant_name || "",
|
||
summary,
|
||
borrowing_points: safeArray(item?.borrowing_points),
|
||
is_high_value: isHighValue
|
||
};
|
||
})
|
||
.filter((item) => !targetPlatform || item.platform === targetPlatform)
|
||
.sort((left, right) => parseIsoTime(right?.created_at || right?.video?.published_at) - parseIsoTime(left?.created_at || left?.video?.published_at))
|
||
.slice(0, limit);
|
||
}
|
||
|
||
function getSelectedAccount() {
|
||
return appState.selectedWorkspace?.account
|
||
|| appState.accounts.find((item) => item.id === appState.selectedAccountId)
|
||
|| null;
|
||
}
|
||
|
||
function getHighScoreVideos(limit = 3) {
|
||
const items = safeArray(appState.selectedVideos?.items);
|
||
const fallback = safeArray(getSelectedAccount()?.video_summary?.videos);
|
||
const pool = items.length ? items : fallback;
|
||
return pool
|
||
.slice()
|
||
.sort((a, b) => Number(b.score?.performance_score || 0) - Number(a.score?.performance_score || 0))
|
||
.slice(0, limit);
|
||
}
|
||
|
||
function getLatestVideos(limit = 3) {
|
||
const items = safeArray(appState.selectedVideos?.items);
|
||
const fallback = safeArray(getSelectedAccount()?.video_summary?.videos);
|
||
const pool = items.length ? items : fallback;
|
||
return pool
|
||
.slice()
|
||
.sort((a, b) => new Date(b.published_at || 0).getTime() - new Date(a.published_at || 0).getTime())
|
||
.slice(0, limit);
|
||
}
|
||
|
||
function getCurrentTrackingRefreshNotice(platform = getCurrentPlatformValue()) {
|
||
const current = appState.trackingRefreshNotice || null;
|
||
if (!current) return null;
|
||
return normalizePlatformValue(current.platform || "", "") === normalizePlatformValue(platform || "", "") ? current : null;
|
||
}
|
||
|
||
function rememberTrackingRefreshNotice(notice) {
|
||
appState.trackingRefreshNotice = notice ? { ...notice, created_at: notice.created_at || new Date().toISOString() } : null;
|
||
}
|
||
|
||
function summarizeTrackingRefreshPayload(payload, platform, mode = "batch") {
|
||
const normalizedPlatform = normalizePlatformValue(platform || getCurrentPlatformValue(), getCurrentPlatformValue());
|
||
if (!payload || typeof payload !== "object") {
|
||
return null;
|
||
}
|
||
if (mode === "single") {
|
||
if (payload.success === false) {
|
||
return {
|
||
platform: normalizedPlatform,
|
||
mode,
|
||
tone: "orange",
|
||
title: "单账号同步失败",
|
||
summary: `暂时无法刷新该账号:${payload.message || "请稍后重试"}`,
|
||
items: []
|
||
};
|
||
}
|
||
if (payload.sync_job_id) {
|
||
return {
|
||
platform: normalizedPlatform,
|
||
mode,
|
||
tone: "blue",
|
||
title: "单账号同步已排队",
|
||
summary: `已为「${payload.account?.nickname || payload.tracked_account_id || "当前账号"}」创建后台同步任务,稍后会把结果回流到日报和作品区。`,
|
||
items: [{
|
||
tracked_account_id: payload.tracked_account_id || "",
|
||
sync_job_id: payload.sync_job_id,
|
||
status: payload.status || "queued"
|
||
}]
|
||
};
|
||
}
|
||
return {
|
||
platform: normalizedPlatform,
|
||
mode,
|
||
tone: safeArray(payload.sync_errors).length ? "orange" : "green",
|
||
title: "单账号已刷新",
|
||
summary: `已刷新「${payload.account?.nickname || payload.tracked_account_id || "当前账号"}」的最新作品。`,
|
||
items: []
|
||
};
|
||
}
|
||
const queuedItems = safeArray(payload.items).filter((item) => item.sync_job_id);
|
||
if (queuedItems.length) {
|
||
return {
|
||
platform: normalizedPlatform,
|
||
mode,
|
||
tone: payload.failed ? "orange" : "blue",
|
||
title: "批量同步已排队",
|
||
summary: `已为 ${formatNumber(queuedItems.length)} 个跟踪账号创建后台同步任务${payload.failed ? `,另有 ${formatNumber(payload.failed)} 个失败` : ""}。`,
|
||
items: queuedItems
|
||
};
|
||
}
|
||
return {
|
||
platform: normalizedPlatform,
|
||
mode,
|
||
tone: payload.failed ? "orange" : "green",
|
||
title: "批量同步已完成",
|
||
summary: `已刷新 ${formatNumber(payload.refreshed || 0)} 个账号${payload.failed ? `,失败 ${formatNumber(payload.failed)} 个` : ""}。`,
|
||
items: []
|
||
};
|
||
}
|
||
|
||
function getSelectedTopVideoAnalysisResult() {
|
||
const accountId = String(getSelectedAccount()?.id || "").trim();
|
||
if (!accountId) return null;
|
||
return appState.topVideoAnalysisResults?.[accountId] || null;
|
||
}
|
||
|
||
function getProductionWorks(limit = 6) {
|
||
const preferred = safeArray(appState.selectedVideos?.items);
|
||
const fallback = safeArray(appState.accounts)
|
||
.flatMap((account) => safeArray(account.video_summary?.videos))
|
||
.filter(Boolean);
|
||
const pool = preferred.length ? preferred : fallback;
|
||
const scored = pool
|
||
.slice()
|
||
.sort((a, b) => Number(b.score?.performance_score || 0) - Number(a.score?.performance_score || 0))
|
||
.slice(0, Math.ceil(limit / 2));
|
||
const latest = pool
|
||
.slice()
|
||
.sort((a, b) => new Date(b.published_at || 0).getTime() - new Date(a.published_at || 0).getTime())
|
||
.slice(0, Math.ceil(limit / 2) + 1);
|
||
const deduped = [];
|
||
const seen = new Set();
|
||
[...scored, ...latest].forEach((item) => {
|
||
const key = item.aweme_id || item.share_url || item.title || item.description;
|
||
if (!key || seen.has(key)) return;
|
||
seen.add(key);
|
||
deduped.push(item);
|
||
});
|
||
return deduped.slice(0, limit);
|
||
}
|
||
|
||
function describeVideo(video) {
|
||
return video.title || video.description || video.aweme_id || "未命名作品";
|
||
}
|
||
|
||
function getVideoLink(video) {
|
||
return video.share_url || video.play_url || "";
|
||
}
|
||
|
||
async function loadJobDetail(jobId) {
|
||
const [job, events, childJobs] = await Promise.all([
|
||
storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}`),
|
||
storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}/events`).catch(() => []),
|
||
storyforgeFetch(`/v2/explore/jobs?parent_job_id=${encodeURIComponent(jobId)}`).catch(() => [])
|
||
]);
|
||
appState.lastJobDetail = { job, events: safeArray(events), childJobs: safeArray(childJobs) };
|
||
return appState.lastJobDetail;
|
||
}
|
||
|
||
function isJobCompleted(job) {
|
||
return String(job?.status || "").toLowerCase() === "completed";
|
||
}
|
||
|
||
function canDeriveAiVideo(job) {
|
||
if (!job || !isJobCompleted(job)) return false;
|
||
return String(job.line_type || "").toLowerCase() !== "ai_video";
|
||
}
|
||
|
||
function canDeriveRealCut(job) {
|
||
if (!job || !isJobCompleted(job)) return false;
|
||
const sourceType = String(job.source_type || "").toLowerCase();
|
||
return ["video_link", "upload_video"].includes(sourceType);
|
||
}
|
||
|
||
function hasIntegrationHealthData() {
|
||
return Boolean(appState.integrationHealth && typeof appState.integrationHealth === "object");
|
||
}
|
||
|
||
function getIntegrationDetail(key) {
|
||
const raw = hasIntegrationHealthData() ? appState.integrationHealth?.[key] : null;
|
||
return {
|
||
key,
|
||
available: Boolean(raw && typeof raw === "object"),
|
||
configured: Boolean(raw?.configured),
|
||
reachable: Boolean(raw?.reachable),
|
||
statusCode: Number(raw?.status_code || 0),
|
||
error: String(raw?.error || ""),
|
||
url: String(raw?.url || raw?.base_url || ""),
|
||
baseUrl: String(raw?.base_url || ""),
|
||
routeMode: String(raw?.route_mode || ""),
|
||
supportsUploads: raw?.supports_uploads !== undefined ? Boolean(raw?.supports_uploads) : true,
|
||
uploadStatusCode: Number(raw?.upload_status_code || 0),
|
||
uploadError: String(raw?.upload_error || ""),
|
||
uploadUrl: String(raw?.upload_url || "")
|
||
};
|
||
}
|
||
|
||
function isFnosTunnelCutvideo(detail) {
|
||
if (!detail || detail.key !== "cutvideo") return false;
|
||
if (detail.routeMode) return detail.routeMode === "fnos_tunnel";
|
||
const baseUrl = String(detail.baseUrl || detail.url || "");
|
||
return /:19186(?:\/|$)/.test(baseUrl);
|
||
}
|
||
|
||
function getCutvideoIntegrationHint(detail) {
|
||
if (isFnosTunnelCutvideo(detail)) {
|
||
return "fnOS NAS 隧道入口";
|
||
}
|
||
return "Windows 直连";
|
||
}
|
||
|
||
function getCutvideoIntegrationUrlLabel(detail) {
|
||
return isFnosTunnelCutvideo(detail) ? "fnOS NAS 隧道入口" : "Windows 直连";
|
||
}
|
||
|
||
function getIntegrationStatus(detail) {
|
||
if (!detail.available) {
|
||
return { tone: "blue", summary: "未拉取" };
|
||
}
|
||
if (detail.key === "cutvideo" && detail.reachable && !detail.supportsUploads) {
|
||
return { tone: "orange", summary: "缺上传能力" };
|
||
}
|
||
if (detail.reachable) {
|
||
return { tone: "green", summary: "在线" };
|
||
}
|
||
if (detail.configured) {
|
||
return { tone: "red", summary: "不可达" };
|
||
}
|
||
return { tone: "orange", summary: "未配置" };
|
||
}
|
||
|
||
function describeIntegrationFailure(key) {
|
||
const detail = getIntegrationDetail(key);
|
||
const meta = INTEGRATION_META[key] || { label: key };
|
||
if (!detail.available) return `${meta.label}健康状态未拉取`;
|
||
if (key === "cutvideo" && detail.reachable && !detail.supportsUploads) {
|
||
return `${meta.label}缺少 /api/uploads`;
|
||
}
|
||
if (!detail.configured) return `${meta.label}未配置`;
|
||
if (detail.statusCode) return `${meta.label}返回 HTTP ${detail.statusCode}`;
|
||
if (detail.error) return `${meta.label}${brief(detail.error, 42)}`;
|
||
return `${meta.label}不可达`;
|
||
}
|
||
|
||
function getPipelineGuard(kind) {
|
||
const config = PIPELINE_GUARDS[kind];
|
||
if (!config) {
|
||
return { enabled: true, reason: "", blocked: [] };
|
||
}
|
||
const blocked = config.dependencies
|
||
.map((key) => ({ key, detail: getIntegrationDetail(key), meta: INTEGRATION_META[key] || { label: key } }))
|
||
.filter((item) => {
|
||
if (!item.detail.available) return false;
|
||
if (!item.detail.reachable) return true;
|
||
if (item.key === "cutvideo" && !item.detail.supportsUploads) return true;
|
||
return false;
|
||
});
|
||
if (!blocked.length) {
|
||
return { enabled: true, reason: "", blocked: [] };
|
||
}
|
||
return {
|
||
enabled: false,
|
||
blocked,
|
||
reason: `${config.label}暂不可用:${blocked.map((item) => describeIntegrationFailure(item.key)).join(";")}`
|
||
};
|
||
}
|
||
|
||
function getIntegrationCards() {
|
||
const currentModel = getCurrentModelProfile();
|
||
const localCatalog = appState.localModelCatalog || {};
|
||
return INTEGRATION_ORDER.map((key) => {
|
||
const detail = getIntegrationDetail(key);
|
||
const status = getIntegrationStatus(detail);
|
||
const meta = INTEGRATION_META[key] || { label: key, hint: key, impacts: [] };
|
||
const metaHint = key === "cutvideo" ? getCutvideoIntegrationHint(detail) : meta.hint;
|
||
let note = "尚未获取健康检查数据";
|
||
if (detail.available) {
|
||
if (detail.reachable) {
|
||
if (key === "cutvideo" && !detail.supportsUploads) {
|
||
note = detail.uploadStatusCode
|
||
? `主服务在线,但 /api/uploads 返回 HTTP ${detail.uploadStatusCode}`
|
||
: (detail.uploadError ? brief(detail.uploadError, 72) : "主服务在线,但缺少上传接口");
|
||
} else if (key === "cutvideo" && isFnosTunnelCutvideo(detail)) {
|
||
note = "当前走 fnOS NAS 隧道,不是 Windows 直连 cutvideo";
|
||
} else {
|
||
note = detail.statusCode
|
||
? `健康探测返回 HTTP ${detail.statusCode}`
|
||
: "TCP 探测已通过";
|
||
}
|
||
} else if (!detail.configured) {
|
||
note = "后端还没有配置该依赖地址";
|
||
} else if (detail.statusCode) {
|
||
note = `探测返回 HTTP ${detail.statusCode}`;
|
||
} else if (detail.error) {
|
||
note = brief(detail.error, 72);
|
||
} else {
|
||
note = "探测失败,请检查服务进程和网络";
|
||
}
|
||
}
|
||
let extra = "";
|
||
let actions = "";
|
||
if (key === "local_model") {
|
||
const availableModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean);
|
||
extra = currentModel
|
||
? `当前主模型:${currentModel.name} · ${currentModel.model_name || "-"}`
|
||
: `默认模型:${localCatalog.default_model || "GLM-5"}`;
|
||
if (availableModels.length) {
|
||
extra += ` · 可用:${availableModels.slice(0, 4).join(" / ")}${availableModels.length > 4 ? "…" : ""}`;
|
||
}
|
||
actions = [
|
||
localCatalog.management_url
|
||
? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>`
|
||
: "",
|
||
`<span class="tag clickable-tag" data-action="open-preferred-model">设主模型</span>`
|
||
].filter(Boolean).join("");
|
||
}
|
||
if (key === "live_recorder") {
|
||
const ownedSources = safeArray(appState.liveRecorderSources);
|
||
const ownedFiles = safeArray(appState.liveRecorderFiles);
|
||
const activeCount = Number(appState.liveRecorderStatus?.recording_count || 0);
|
||
extra = ownedSources.length
|
||
? `我的录制源 ${ownedSources.length} · 录像 ${ownedFiles.length} · 正在录制 ${activeCount}`
|
||
: "当前还没有你的录制源";
|
||
actions = `<span class="tag clickable-tag" data-action="open-live-recorder">录制控制</span>`;
|
||
}
|
||
if (key === "cutvideo") {
|
||
extra = isFnosTunnelCutvideo(detail)
|
||
? `当前通过 fnOS NAS 隧道访问 ${detail.baseUrl || detail.url || "cutvideo"}`
|
||
: `当前直连 ${detail.baseUrl || detail.url || "cutvideo"}`;
|
||
}
|
||
return {
|
||
key,
|
||
meta: {
|
||
...meta,
|
||
hint: metaHint
|
||
},
|
||
detail,
|
||
status,
|
||
note,
|
||
extra,
|
||
actions
|
||
};
|
||
});
|
||
}
|
||
|
||
function renderLiveRecorderSummaryHtml() {
|
||
const sources = safeArray(appState.liveRecorderSources);
|
||
const files = safeArray(appState.liveRecorderFiles);
|
||
const status = appState.liveRecorderStatus || {};
|
||
const activeItems = safeArray(status.active_recordings);
|
||
const sourceHtml = sources.slice(0, 4).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.title || item.remote_name || item.source_url || "录制源")}</h4>
|
||
<p>${escapeHtml(platformLabel(item.platform))} · ${escapeHtml(item.quality || "原画")} · ${escapeHtml(item.enabled ? "启用中" : "已停用")}</p>
|
||
</div>
|
||
`).join("");
|
||
const fileHtml = files.slice(0, 4).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.title || item.name || "录像文件")}</h4>
|
||
<p>${escapeHtml(item.mtime || "-")} · ${escapeHtml(item.name || item.relative_path || "-")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue clickable-tag" data-action="open-live-recorder-file" data-file-id="${escapeHtml(item.id || "")}">打开录像</span>
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>租户隔离状态</h4>
|
||
<p>当前只展示你自己名下的录制源、活动录制和录像文件。全局 NAS 配置不会直接暴露给前端。</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(`录制源 ${sources.length}`)}</span>
|
||
<span class="tag">${escapeHtml(`活动 ${activeItems.length}`)}</span>
|
||
<span class="tag">${escapeHtml(`文件 ${files.length}`)}</span>
|
||
</div>
|
||
</div>
|
||
${sourceHtml || `<div class="task-item compact"><h4>还没有录制源</h4><p>新增直播源后会自动挂到你的租户空间下。</p></div>`}
|
||
${fileHtml || `<div class="task-item compact"><h4>还没有录像文件</h4><p>录制完成后的文件会只出现在你的当前租户视图里。</p></div>`}
|
||
`;
|
||
}
|
||
|
||
function getStorageItemPath(item) {
|
||
return (
|
||
item?.artifacts?.source_path ||
|
||
item?.artifacts?.uploaded_path ||
|
||
item?.artifacts?.output_path ||
|
||
item?.artifacts?.file_path ||
|
||
item?.result?.source_path ||
|
||
item?.result?.output_path ||
|
||
item?.result?.file_path ||
|
||
item?.result?.path ||
|
||
item?.save_path ||
|
||
item?.path ||
|
||
item?.relative_path ||
|
||
item?.content_url ||
|
||
item?.job_id ||
|
||
item?.id ||
|
||
"-"
|
||
);
|
||
}
|
||
|
||
function renderStorageJobCards(items, emptyTitle, emptyText) {
|
||
return safeArray(items).slice(0, 4).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.title || item.name || item.job_id || "任务")}</h4>
|
||
<p>${escapeHtml(brief(getStorageItemPath(item), 140))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(item.status || "-")}</span>
|
||
${item.project_name ? `<span class="tag">${escapeHtml(item.project_name)}</span>` : ""}
|
||
${item.line_type || item.source_type ? `<span class="tag">${escapeHtml(item.line_type || item.source_type)}</span>` : ""}
|
||
${item.id ? `<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(item.id)}">看详情</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>${escapeHtml(emptyTitle)}</h4><p>${escapeHtml(emptyText)}</p></div>`;
|
||
}
|
||
|
||
function renderStorageFileCards(items, emptyTitle, emptyText) {
|
||
return safeArray(items).slice(0, 4).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.title || item.name || item.relative_path || "文件")}</h4>
|
||
<p>${escapeHtml(brief(item.relative_path || item.name || item.content_url || "-", 140))}</p>
|
||
<div class="task-meta">
|
||
${(item.updated_at || item.mtime) ? `<span class="tag">${escapeHtml(formatDateTime(item.updated_at || item.mtime))}</span>` : ""}
|
||
${(item.size_bytes || item.size) ? `<span class="tag">${escapeHtml(formatBytes(item.size_bytes || item.size))}</span>` : ""}
|
||
${item.id ? `<span class="tag clickable-tag" data-action="${escapeHtml(item.kind === "downloads" || item.kind === "jobs" ? "open-storage-artifact" : "open-live-recorder-file")}" data-file-id="${escapeHtml(item.id)}">打开文件</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>${escapeHtml(emptyTitle)}</h4><p>${escapeHtml(emptyText)}</p></div>`;
|
||
}
|
||
|
||
function renderStorageStatusPanel() {
|
||
const storage = appState.storageStatus;
|
||
const dashboardJobs = safeArray(appState.dashboard?.recent_jobs);
|
||
const currentProject = getSelectedProject();
|
||
const currentAccount = getSelectedAccount();
|
||
const liveRecorderSources = safeArray(appState.liveRecorderSources);
|
||
const liveRecorderFiles = safeArray(appState.liveRecorderFiles);
|
||
const fallbackRecentJobs = dashboardJobs.slice(0, 4);
|
||
const recentJobs = safeArray(storage?.tenant_usage?.recent_jobs).length ? safeArray(storage?.tenant_usage?.recent_jobs) : fallbackRecentJobs;
|
||
const recentFiles = [
|
||
...safeArray(storage?.tenant_usage?.recent_download_artifacts),
|
||
...safeArray(storage?.tenant_usage?.recent_job_artifacts),
|
||
...safeArray(storage?.recent_files || storage?.recent_artifacts || storage?.tenant_usage?.recent_files || storage?.tenant_usage?.recent_artifacts)
|
||
];
|
||
if (!storage) {
|
||
const projectJobCount = appState.selectedProjectId ? dashboardJobs.filter((item) => item.project_id === appState.selectedProjectId).length : dashboardJobs.length;
|
||
const accountJobCount = appState.selectedAccountId ? dashboardJobs.filter((item) => item.account_id === appState.selectedAccountId).length : dashboardJobs.length;
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>存储状态</h3>
|
||
<div class="panel-subtitle">当前实例没有返回存储策略时,先用任务和录像文件做本地观察</div>
|
||
</div>
|
||
<span class="tag blue">降级视图</span>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>未拉取到 NAS 策略</h4>
|
||
<p>当 storage/status 暂时拿不到时,这里会自动退回到任务和录像文件的降级视图。</p>
|
||
<div class="task-meta">
|
||
<span class="tag">最近任务 ${escapeHtml(formatNumber(projectJobCount))}</span>
|
||
<span class="tag">录制源 ${escapeHtml(formatNumber(liveRecorderSources.length))}</span>
|
||
<span class="tag">录像文件 ${escapeHtml(formatNumber(liveRecorderFiles.length))}</span>
|
||
</div>
|
||
</div>
|
||
<div class="mini-grid" style="margin-top:14px;">
|
||
<div class="mini-card">
|
||
<small>当前项目</small>
|
||
<strong>${escapeHtml(currentProject?.name || appState.selectedProjectId || "未选择")}</strong>
|
||
<span>任务 ${escapeHtml(formatNumber(projectJobCount))}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>当前账号</small>
|
||
<strong>${escapeHtml(currentAccount ? getAccountName(currentAccount) : "未选择")}</strong>
|
||
<span>任务 ${escapeHtml(formatNumber(accountJobCount))}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>录像源</small>
|
||
<strong>${escapeHtml(formatNumber(liveRecorderSources.length))}</strong>
|
||
<span>仅当前租户可见</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>最近文件</small>
|
||
<strong>${escapeHtml(formatNumber(liveRecorderFiles.length))}</strong>
|
||
<span>可直接打开</span>
|
||
</div>
|
||
</div>
|
||
<div class="list" style="margin-top:14px;">
|
||
<div class="task-item compact">
|
||
<h4>最近任务</h4>
|
||
<p>优先展示 dashboard.recent_jobs,方便在没有 storage/status 时也能继续追踪产物。</p>
|
||
</div>
|
||
${renderStorageJobCards(
|
||
fallbackRecentJobs,
|
||
"还没有任务样本",
|
||
"等你完成一次分析、下载或剪辑后,这里就会出现最近的任务路径和详情入口。"
|
||
)}
|
||
<div class="task-item compact">
|
||
<h4>最近录像文件</h4>
|
||
<p>录像文件沿用 live-recorder 的当前租户视图,支持直接打开查看。</p>
|
||
</div>
|
||
${renderStorageFileCards(
|
||
liveRecorderFiles,
|
||
"还没有录像文件",
|
||
"录制完成后,这里会直接暴露当前租户的最近文件入口。"
|
||
)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
const strategy = storage.strategy || {};
|
||
const disk = storage.disk || {};
|
||
const usage = storage.tenant_usage || {};
|
||
const strategyMode = (strategy.jobs?.mode || "local").toUpperCase();
|
||
const projectName = currentProject?.name || appState.selectedProjectId || "未选择";
|
||
const accountName = currentAccount ? getAccountName(currentAccount) : "未选择";
|
||
const strategyTags = [
|
||
`数据库 ${strategy.database?.mode || "local"}`,
|
||
`分析缓存 ${strategy.jobs?.mode || "local"}`,
|
||
`下载缓存 ${strategy.downloads?.mode || "local"}`,
|
||
`直播录制 ${strategy.live_recorder?.mode || "nas_service"}`,
|
||
];
|
||
const usageCards = [
|
||
{ label: "当前项目缓存", value: formatBytes(usage.project_jobs?.bytes), sub: `文件 ${formatNumber(usage.project_jobs?.file_count)}` },
|
||
{ label: "当前项目下载", value: formatBytes(usage.project_downloads?.bytes), sub: `文件 ${formatNumber(usage.project_downloads?.file_count)}` },
|
||
{ label: "当前账号缓存", value: formatBytes(usage.account_jobs?.bytes), sub: `文件 ${formatNumber(usage.account_jobs?.file_count)}` },
|
||
{ label: "当前账号下载", value: formatBytes(usage.account_downloads?.bytes), sub: `文件 ${formatNumber(usage.account_downloads?.file_count)}` },
|
||
{ label: "NAS 剩余", value: formatBytes(disk.jobs?.free_bytes), sub: `总量 ${formatBytes(disk.jobs?.total_bytes)}` }
|
||
];
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>存储状态</h3>
|
||
<div class="panel-subtitle">数据库留本机,大文件缓存优先走 NAS</div>
|
||
</div>
|
||
<span class="tag blue">${escapeHtml(strategyMode)}</span>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>当前观察范围</h4>
|
||
<p>${escapeHtml(`项目 ${projectName} · 账号 ${accountName} · 最近任务 ${recentJobs.length} 条 · 最近录像 ${liveRecorderFiles.length} 个`)}</p>
|
||
</div>
|
||
<div class="task-meta">
|
||
${strategyTags.map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join("")}
|
||
</div>
|
||
<div class="mini-grid" style="margin-top:14px;">
|
||
${usageCards.map((item) => `
|
||
<div class="mini-card">
|
||
<small>${escapeHtml(item.label)}</small>
|
||
<strong>${escapeHtml(item.value)}</strong>
|
||
<span>${escapeHtml(item.sub)}</span>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
<div class="two-col" style="margin-top:14px;">
|
||
<div class="task-item compact">
|
||
<h4>目录策略</h4>
|
||
<p>${escapeHtml([
|
||
`数据库 ${strategy.database?.path || "本机"}`,
|
||
`任务缓存 ${usage.project_jobs?.path || strategy.jobs?.path || "-"}`,
|
||
`下载缓存 ${usage.project_downloads?.path || strategy.downloads?.path || "-"}`,
|
||
`录制缓存 ${strategy.live_recorder?.path || strategy.live_recorder?.base_path || "-"}`
|
||
].join(" · "))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">项目层 ${escapeHtml(usage.project_jobs?.path || strategy.jobs?.path || "-")}</span>
|
||
<span class="tag">账号层 ${escapeHtml(usage.account_jobs?.path || "-")}</span>
|
||
</div>
|
||
</div>
|
||
<div class="task-item compact">
|
||
<h4>产物入口</h4>
|
||
<p>最近任务、分析产物和录像文件都能直接点开,便于从 NAS 面板跳回详情或原文件。</p>
|
||
<div class="task-meta">
|
||
<span class="tag">任务 ${escapeHtml(formatNumber(recentJobs.length))}</span>
|
||
<span class="tag">产物 ${escapeHtml(formatNumber(recentFiles.length))}</span>
|
||
<span class="tag">录像 ${escapeHtml(formatNumber(liveRecorderFiles.length))}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="list" style="margin-top:14px;">
|
||
<div class="task-item compact">
|
||
<h4>最近任务样本</h4>
|
||
<p>默认取 storage.status 里的 recent_jobs;如果后端没给,会退回到 dashboard.recent_jobs。</p>
|
||
</div>
|
||
${renderStorageJobCards(
|
||
recentJobs,
|
||
"还没有缓存样本",
|
||
"上传视频、导入作品后,这里会显示最近写入 NAS 的缓存路径。"
|
||
)}
|
||
${recentFiles.length ? `
|
||
<div class="task-item compact">
|
||
<h4>最近产物文件</h4>
|
||
<p>后端如果提供产物文件索引,这里会优先直接露出最近写入的文件入口。</p>
|
||
</div>
|
||
${renderStorageFileCards(
|
||
recentFiles,
|
||
"还没有产物文件",
|
||
"当前 storage/status 没有返回可直接打开的产物文件。"
|
||
)}
|
||
` : ""}
|
||
<div class="task-item compact">
|
||
<h4>最近录像文件</h4>
|
||
<p>如果 live-recorder 已接入,这里会继续显示当前租户的录像文件入口。</p>
|
||
</div>
|
||
${renderStorageFileCards(
|
||
liveRecorderFiles,
|
||
"还没有录像文件",
|
||
"录制完成后的文件会出现在当前租户的录像列表里。"
|
||
)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderOneLinerActionRegistryPanel() {
|
||
const items = safeArray(appState.onelinerActionRegistry);
|
||
if (!items.length) {
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>OneLiner 动作注册表</h3>
|
||
<div class="panel-subtitle">当前项目还没有单独动作配置,OneLiner 会继续沿用系统默认动作。</div>
|
||
</div>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>当前沿用系统默认动作</h4>
|
||
<p>你可以等真实使用场景稳定后,再回来给当前项目单独开关、改名或补充动作说明。</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">系统默认仍可执行</span>
|
||
<span class="tag">当前项目未单独覆盖</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
const grouped = items.reduce((acc, item) => {
|
||
const category = item.category || "custom";
|
||
acc[category] = acc[category] || [];
|
||
acc[category].push(item);
|
||
return acc;
|
||
}, {});
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>OneLiner 动作注册表</h3>
|
||
<div class="panel-subtitle">把 OneLiner 可执行动作做成租户级注册中心,便于商业化灰度和定制。</div>
|
||
</div>
|
||
<span class="tag blue">${escapeHtml(formatNumber(items.length))} 条</span>
|
||
</div>
|
||
<div class="list">
|
||
${Object.entries(grouped).map(([category, list]) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(category)}</h4>
|
||
<p>${escapeHtml(`当前分类下 ${list.length} 条动作。`)}</p>
|
||
<div class="task-meta">
|
||
${list.map((item) => `
|
||
<span class="tag ${item.status === "enabled" ? "green" : "orange"} clickable-tag" data-action="open-action-registry-edit" data-action-key="${escapeHtml(item.action_key || "")}">
|
||
${escapeHtml(item.label || item.action_key || "action")}
|
||
</span>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderTenantQuotaPanel() {
|
||
const quota = appState.tenantQuota;
|
||
const usage = appState.tenantUsage || quota?.usage || {};
|
||
const quotaNotice = renderQuotaBlockingNotice();
|
||
if (!quota && !usage) {
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>租户额度与审计</h3>
|
||
<div class="panel-subtitle">当前项目还没有额度配置,会先按默认不限额模式运行。</div>
|
||
</div>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>当前项目还没有额度配置</h4>
|
||
<p>先给这个项目补预算、动作配额和存储上限,再逐步收紧风险控制。</p>
|
||
<div class="task-meta">
|
||
<span class="tag clickable-tag" data-action="open-tenant-quota">创建额度策略</span>
|
||
<span class="tag blue">默认不限额</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
const categories = usage?.categories || {};
|
||
const recentItems = safeArray(usage?.recent_items);
|
||
const categoryEntries = Object.values(categories || {}).sort((left, right) => (right?.cost_cents || 0) - (left?.cost_cents || 0));
|
||
const topCategory = categoryEntries[0] || null;
|
||
const hasHardLimit = Boolean(
|
||
(quota?.monthly_budget_cents || 0) > 0 ||
|
||
(quota?.storage_limit_bytes || 0) > 0 ||
|
||
(quota?.analysis_quota || 0) > 0 ||
|
||
(quota?.copy_quota || 0) > 0 ||
|
||
(quota?.ai_video_quota || 0) > 0 ||
|
||
(quota?.real_cut_quota || 0) > 0 ||
|
||
(quota?.recorder_quota || 0) > 0
|
||
);
|
||
const usageCount = recentItems.length;
|
||
const quotaTaskTitle = quota?.storage_over_limit
|
||
? "先处理存储超限"
|
||
: quota?.enabled === false
|
||
? "先恢复额度保护"
|
||
: !hasHardLimit
|
||
? "先补项目额度策略"
|
||
: usageCount
|
||
? "先检查本周期消耗"
|
||
: "先跑出第一条计量";
|
||
const quotaTaskSummary = quota?.storage_over_limit
|
||
? "当前项目已经命中存储上限,先调整额度或清理产物,再继续高成本动作。"
|
||
: quota?.enabled === false
|
||
? "额度保护已关闭,当前项目会按无限制模式运行,建议尽快恢复预算与动作保护。"
|
||
: !hasHardLimit
|
||
? "当前项目虽然可继续运行,但还没有预算和动作配额,先把保护线补齐更稳。"
|
||
: usageCount
|
||
? "本周期已经开始消耗额度,先看最主要的消耗来源,再决定是否收紧策略。"
|
||
: "额度已经建好,但当前周期还没有实际计量,先触发一次真实动作更容易校准策略。";
|
||
const quotaTaskActionLabel = quota?.storage_over_limit || !hasHardLimit || quota?.enabled === false ? "调整额度" : usageCount ? "查看最近计量" : "去跑动作";
|
||
const quotaTaskAction = quotaTaskActionLabel === "去跑动作" ? "goto-production" : quotaTaskActionLabel === "查看最近计量" ? "" : "open-tenant-quota";
|
||
const usagePeriodLabel = usage?.cycle_start ? formatDateTime(usage.cycle_start).slice(0, 10) : "本周期";
|
||
const quotaHealthTags = [
|
||
quota?.enabled === false ? `<span class="tag orange">额度保护关闭</span>` : `<span class="tag green">额度保护开启</span>`,
|
||
quota?.storage_over_limit ? `<span class="tag red">存储超限</span>` : `<span class="tag blue">存储正常</span>`,
|
||
topCategory ? `<span class="tag">${escapeHtml(`主要消耗 ${topCategory.category || "usage"}`)}</span>` : `<span class="tag">本周期未产生消耗</span>`
|
||
];
|
||
const cards = [
|
||
{ label: "预算", value: `${formatNumber((quota?.monthly_budget_cents || 0) / 100)} 元`, sub: `已用 ${formatNumber((usage?.total_cost_cents || 0) / 100)} 元` },
|
||
{ label: "分析配额", value: formatNumber(quota?.analysis_quota || 0), sub: `已用 ${formatNumber(categories.analysis?.quantity || 0)}` },
|
||
{ label: "文案配额", value: formatNumber(quota?.copy_quota || 0), sub: `已用 ${formatNumber(categories.copy?.quantity || 0)}` },
|
||
{ label: "AI 视频配额", value: formatNumber(quota?.ai_video_quota || 0), sub: `已用 ${formatNumber(categories.ai_video?.quantity || 0)}` },
|
||
{ label: "实拍剪辑配额", value: formatNumber(quota?.real_cut_quota || 0), sub: `已用 ${formatNumber(categories.real_cut?.quantity || 0)}` },
|
||
{ label: "存储上限", value: formatBytes(quota?.storage_limit_bytes || 0), sub: `当前 ${formatBytes(usage?.storage_bytes || 0)}` }
|
||
];
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>租户额度与审计</h3>
|
||
<div class="panel-subtitle">预算、动作配额和最近计量都按租户 + 项目隔离,首屏先看风险和下一步。</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag ${quota?.enabled === false ? "orange" : "green"}">${escapeHtml(quota?.enabled === false ? "已停用额度保护" : "额度保护开启")}</span>
|
||
${quota?.storage_over_limit ? `<span class="tag red">存储超限</span>` : ""}
|
||
<span class="tag clickable-tag" data-action="open-tenant-quota">编辑额度</span>
|
||
</div>
|
||
</div>
|
||
<div class="task-item" style="margin-top:14px;">
|
||
<h4>${escapeHtml(quotaTaskTitle)}</h4>
|
||
<p>${escapeHtml(quotaTaskSummary)}</p>
|
||
<div class="task-meta">
|
||
${quotaTaskAction ? `<span class="tag clickable-tag" data-action="${escapeHtml(quotaTaskAction)}">${escapeHtml(quotaTaskActionLabel)}</span>` : `<span class="tag blue">${escapeHtml(quotaTaskActionLabel)}</span>`}
|
||
${quotaHealthTags.join("")}
|
||
</div>
|
||
</div>
|
||
<div class="compact-summary-row" style="margin-top:14px;">
|
||
<span class="tag blue">${escapeHtml(`周期 ${usagePeriodLabel}`)}</span>
|
||
<span class="tag green">${escapeHtml(`计量 ${formatNumber(usageCount)} 条`)}</span>
|
||
<span class="tag">${escapeHtml(`成本 ${(usage?.total_cost_cents || 0) / 100} 元`)}</span>
|
||
<span class="tag">${escapeHtml(`主要消耗 ${topCategory?.category || "暂无"}`)}</span>
|
||
</div>
|
||
${quotaNotice}
|
||
<div class="mini-grid" style="margin-top:14px;">
|
||
${cards.map((item) => `
|
||
<div class="mini-card">
|
||
<small>${escapeHtml(item.label)}</small>
|
||
<strong>${escapeHtml(item.value)}</strong>
|
||
<span>${escapeHtml(item.sub)}</span>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
<div class="list" style="margin-top:14px;">
|
||
<div class="task-item compact">
|
||
<h4>最近计量记录</h4>
|
||
<p>动作执行后会写入租户级 ledger,便于后面做商业化配额、成本和审计。</p>
|
||
</div>
|
||
${recentItems.map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.category || "usage")}</h4>
|
||
<p>${escapeHtml(formatDateTime(item.created_at))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">次数 ${escapeHtml(formatNumber(item.quantity || 0))}</span>
|
||
<span class="tag">成本 ${(item.cost_cents || 0) / 100} 元</span>
|
||
${item.reference_type ? `<span class="tag">${escapeHtml(item.reference_type)}</span>` : ""}
|
||
${item.reference_id ? `<span class="tag">${escapeHtml(brief(item.reference_id, 14))}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>还没有计量记录</h4><p>等 OneLiner 或生产动作实际执行后,这里会累积本周期的 usage ledger。</p></div>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function policyScopeTagLabel(scopeKind, platform = "") {
|
||
if (scopeKind === "system_main") return "系统默认";
|
||
if (scopeKind === "system_platform") return `${platformLabel(platform || "douyin")} 默认`;
|
||
if (scopeKind === "user_global") return "我的全局";
|
||
if (scopeKind === "user_platform") return `${platformLabel(platform || "douyin")} 我的策略`;
|
||
if (scopeKind === "admin_override") return "管理员覆盖";
|
||
return "策略层";
|
||
}
|
||
|
||
function summarizePolicyHighlights(policy = {}, platform = "") {
|
||
const items = [];
|
||
if (policy?.tone?.style) items.push(`语气 ${policy.tone.style}`);
|
||
if (policy?.actions?.max_cards != null) items.push(`首页动作 ${formatNumber(policy.actions.max_cards)} 条`);
|
||
if (policy?.memory?.default_window) items.push(`记忆窗口 ${policy.memory.default_window}`);
|
||
if (platform && policy?.[platform]?.benchmark_mode) items.push(`${platformLabel(platform)} 对标 ${policy[platform].benchmark_mode}`);
|
||
if (policy?.guardrails?.require_admin_review) items.push("需管理员复核");
|
||
return items.slice(0, 4);
|
||
}
|
||
|
||
function getGovernanceDirectoryItems() {
|
||
return safeArray(appState.adminGovernanceDirectory?.items || appState.adminGovernanceDirectory);
|
||
}
|
||
|
||
function parseAdminOverrideScopeValue(value) {
|
||
const normalized = String(value || "").trim();
|
||
if (!normalized) return { targetUserId: "", targetProjectId: "" };
|
||
if (normalized.startsWith("project:")) {
|
||
const parts = normalized.slice("project:".length).split("|");
|
||
return { targetUserId: parts[0] || "", targetProjectId: parts[1] || "" };
|
||
}
|
||
if (normalized.startsWith("user:")) {
|
||
return { targetUserId: normalized.slice("user:".length), targetProjectId: "" };
|
||
}
|
||
return { targetUserId: normalized, targetProjectId: "" };
|
||
}
|
||
|
||
function getAdminOverrideTargetOptions(directoryItems = getGovernanceDirectoryItems()) {
|
||
return safeArray(directoryItems).flatMap((account) => {
|
||
const accountLabel = account.display_name || account.username || account.id;
|
||
const projects = safeArray(account.projects);
|
||
return [
|
||
{ value: `user:${account.id}`, label: `${accountLabel} · 全部项目` },
|
||
...projects.map((project) => ({
|
||
value: `project:${account.id}|${project.id}`,
|
||
label: `${accountLabel} / ${project.name || project.id}`
|
||
}))
|
||
];
|
||
});
|
||
}
|
||
|
||
function normalizeAdminOverrideTarget(target, directoryItems = getGovernanceDirectoryItems(), fallbackPlatform = "") {
|
||
const items = safeArray(directoryItems);
|
||
if (!items.length) {
|
||
return { targetUserId: "", targetProjectId: "", platform: normalizePlatformValue(fallbackPlatform, "douyin") };
|
||
}
|
||
const preferred = target && target.targetUserId
|
||
? items.find((item) => item.id === target.targetUserId)
|
||
: items.find((item) => item.role !== "super_admin") || items[0];
|
||
const preferredProjects = safeArray(preferred?.projects);
|
||
const project =
|
||
target?.targetProjectId
|
||
? preferredProjects.find((item) => item.id === target.targetProjectId)
|
||
: preferredProjects[0] || null;
|
||
return {
|
||
targetUserId: preferred?.id || "",
|
||
targetProjectId: project?.id || "",
|
||
platform: target?.platform === "" ? "" : normalizePlatformValue(target?.platform || fallbackPlatform, "douyin")
|
||
};
|
||
}
|
||
|
||
function findGovernanceDirectoryAccount(userId) {
|
||
return getGovernanceDirectoryItems().find((item) => item.id === userId) || null;
|
||
}
|
||
|
||
function findGovernanceDirectoryProject(userId, projectId) {
|
||
const account = findGovernanceDirectoryAccount(userId);
|
||
return safeArray(account?.projects).find((item) => item.id === projectId) || null;
|
||
}
|
||
|
||
function getAdminOverrideTargetSummary(target = appState.adminOverrideTarget) {
|
||
const normalized = target || {};
|
||
const account = findGovernanceDirectoryAccount(normalized.targetUserId || "");
|
||
const project = normalized.targetProjectId ? findGovernanceDirectoryProject(normalized.targetUserId || "", normalized.targetProjectId) : null;
|
||
return {
|
||
account,
|
||
project,
|
||
accountLabel: account?.display_name || account?.username || normalized.targetUserId || "未选择用户",
|
||
projectLabel: project?.name || (normalized.targetProjectId ? normalized.targetProjectId : "全部项目"),
|
||
platformLabel: normalized.platform ? platformLabel(normalized.platform) : "全部平台"
|
||
};
|
||
}
|
||
|
||
function renderGovernanceSummaryCard({ title, subtitle, effective, primaryAction = "", primaryLabel = "编辑策略", secondaryAction = "", secondaryLabel = "", secondaryPlatform = "", actions = null }) {
|
||
const layers = safeArray(effective?.layers);
|
||
const highlights = summarizePolicyHighlights(effective?.effective_policy || {}, effective?.platform || secondaryPlatform || "");
|
||
const activeAdminOverrideNotice = effective?.active_admin_override_notice || null;
|
||
const resolvedActions = safeArray(actions?.length ? actions : [
|
||
primaryAction ? { action: primaryAction, label: primaryLabel } : null,
|
||
secondaryAction ? { action: secondaryAction, label: secondaryLabel, platform: secondaryPlatform } : null
|
||
].filter(Boolean));
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(title)}</h4>
|
||
<p>${escapeHtml(subtitle || "当前还没有策略摘要。")}</p>
|
||
<div class="task-meta">
|
||
${layers.map((layer) => `<span class="tag ${layer.scope_kind === "admin_override" ? "orange" : "blue"}">${escapeHtml(policyScopeTagLabel(layer.scope_kind, layer.scope?.platform || effective?.platform || ""))}</span>`).join("") || `<span class="tag">尚未发布</span>`}
|
||
${highlights.map((item) => `<span class="tag green">${escapeHtml(item)}</span>`).join("")}
|
||
</div>
|
||
${activeAdminOverrideNotice?.title ? `
|
||
<div class="task-item compact" style="margin-top:10px; border-color:rgba(245, 158, 11, 0.28); background:linear-gradient(180deg, rgba(255, 250, 240, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);">
|
||
<h4>管理员覆盖生效中</h4>
|
||
<p>${escapeHtml(activeAdminOverrideNotice.summary || activeAdminOverrideNotice.title || "当前这层管理员覆盖会优先于你的个人策略生效。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag orange">${escapeHtml(activeAdminOverrideNotice.title || "管理员覆盖")}</span>
|
||
${activeAdminOverrideNotice.platform_label ? `<span class="tag">${escapeHtml(activeAdminOverrideNotice.platform_label)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${resolvedActions.length ? `
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
${resolvedActions.map((item) => `
|
||
<span class="tag clickable-tag" data-action="${escapeHtml(item.action || "")}"
|
||
${item.platform ? `data-platform="${escapeHtml(item.platform)}"` : ""}
|
||
${item.sourceActionKey ? `data-source-action-key="${escapeHtml(item.sourceActionKey)}"` : ""}
|
||
${item.sourceScreen ? `data-source-screen="${escapeHtml(item.sourceScreen)}"` : ""}
|
||
${item.intentKey ? `data-intent-key="${escapeHtml(item.intentKey)}"` : ""}
|
||
${item.title ? `data-title="${escapeHtml(item.title)}"` : ""}
|
||
${item.goal ? `data-goal="${escapeHtml(item.goal)}"` : ""}
|
||
${item.summary ? `data-summary="${escapeHtml(item.summary)}"` : ""}
|
||
${item.platformScope ? `data-platform-scope="${escapeHtml(item.platformScope)}"` : ""}
|
||
${item.planSteps ? `data-plan-steps="${escapeHtml(JSON.stringify(item.planSteps))}"` : ""}>
|
||
${escapeHtml(item.label || "查看")}
|
||
</span>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderAdminGovernanceSummaryPanel() {
|
||
const systemMain = appState.adminSystemMainPolicy;
|
||
const systemPlatforms = safeArray(appState.adminSystemPlatformPolicies);
|
||
const configuredPlatforms = systemPlatforms.filter((item) => item?.current_version);
|
||
const targetSummary = getAdminOverrideTargetSummary();
|
||
const overrideBundle = appState.adminOverridePolicy;
|
||
return `
|
||
<div class="panel pad" style="box-shadow:none; margin-bottom:18px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>系统级主 Agent 治理</h3>
|
||
<div class="panel-subtitle">先管系统默认主 Agent,再按平台补默认策略,普通用户的个性化覆盖会叠加在这些底座之上。</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag blue">系统主 Agent ${escapeHtml(systemMain?.current_version ? "已发布" : "未发布")}</span>
|
||
<span class="tag">${escapeHtml(formatNumber(configuredPlatforms.length))} 个平台默认策略</span>
|
||
<span class="tag clickable-tag" data-action="open-system-main-policy">编辑系统主 Agent</span>
|
||
</div>
|
||
</div>
|
||
<div class="three-col">
|
||
<div class="entity-card pad">
|
||
<div class="cell-title">系统主 Agent</div>
|
||
<div class="cell-desc">${escapeHtml(systemMain?.current_version?.summary || "还没有发布系统默认主 Agent 策略。")}</div>
|
||
<div class="entity-meta">
|
||
<span class="tag ${systemMain?.current_version ? "green" : "orange"}">${escapeHtml(systemMain?.current_version ? `版本 ${formatNumber(systemMain.current_version.version_no)}` : "未发布")}</span>
|
||
<span class="tag">历史 ${escapeHtml(formatNumber(systemMain?.versions?.count || 0))}</span>
|
||
<span class="tag clickable-tag" data-action="open-system-main-policy-history">历史与回滚</span>
|
||
</div>
|
||
</div>
|
||
<div class="entity-card pad">
|
||
<div class="cell-title">管理员覆盖</div>
|
||
<div class="cell-desc">${escapeHtml(overrideBundle?.current_version?.summary || `${targetSummary.accountLabel} / ${targetSummary.projectLabel} / ${targetSummary.platformLabel}`)}</div>
|
||
<div class="entity-meta">
|
||
<span class="tag blue">${escapeHtml(targetSummary.accountLabel)}</span>
|
||
<span class="tag">${escapeHtml(targetSummary.projectLabel)}</span>
|
||
<span class="tag">${escapeHtml(targetSummary.platformLabel)}</span>
|
||
<span class="tag ${overrideBundle?.current_version ? "orange" : "blue"}">${escapeHtml(overrideBundle?.current_version ? `版本 ${formatNumber(overrideBundle.current_version.version_no)}` : "未发布")}</span>
|
||
<span class="tag clickable-tag" data-action="open-admin-override-target">切换目标</span>
|
||
<span class="tag clickable-tag" data-action="open-admin-override-policy">编辑覆盖</span>
|
||
<span class="tag clickable-tag" data-action="open-admin-override-history">历史与回滚</span>
|
||
</div>
|
||
</div>
|
||
${ACTIVE_PLATFORMS.map((platformItem) => {
|
||
const item = systemPlatforms.find((entry) => entry?.scope?.platform === platformItem.value) || null;
|
||
return `
|
||
<div class="entity-card pad">
|
||
<div class="cell-title">${escapeHtml(platformItem.label)} 默认策略</div>
|
||
<div class="cell-desc">${escapeHtml(item?.current_version?.summary || "还没有平台默认策略,当前会沿用系统主 Agent 默认。")}</div>
|
||
<div class="entity-meta">
|
||
<span class="tag ${item?.current_version ? "green" : "blue"}">${escapeHtml(item?.current_version ? `版本 ${formatNumber(item.current_version.version_no)}` : "沿用系统默认")}</span>
|
||
<span class="tag">历史 ${escapeHtml(formatNumber(item?.versions?.count || 0))}</span>
|
||
<span class="tag clickable-tag" data-action="open-system-platform-policy" data-platform="${escapeHtml(platformItem.value)}">编辑</span>
|
||
<span class="tag clickable-tag" data-action="open-system-platform-policy-history" data-platform="${escapeHtml(platformItem.value)}">历史与回滚</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderAdminGovernanceAuditPanel() {
|
||
const targetSummary = getAdminOverrideTargetSummary();
|
||
const audits = safeArray(appState.adminPolicyAudits);
|
||
return `
|
||
<div class="panel pad" style="box-shadow:none; margin-top:18px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>覆盖与审计</h3>
|
||
<div class="panel-subtitle">按当前目标查看管理员覆盖、系统默认和相关策略变更,避免治理动作只留在弹窗里。</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(targetSummary.accountLabel)}</span>
|
||
<span class="tag">${escapeHtml(targetSummary.projectLabel)}</span>
|
||
<span class="tag">${escapeHtml(targetSummary.platformLabel)}</span>
|
||
<span class="tag clickable-tag" data-action="open-admin-override-target">切换目标</span>
|
||
</div>
|
||
</div>
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="task-item compact">
|
||
<h4>当前管理员覆盖</h4>
|
||
<p>${escapeHtml(appState.adminOverridePolicy?.current_version?.summary || "当前目标还没有管理员覆盖版本。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${appState.adminOverridePolicy?.current_version ? "orange" : "blue"}">${escapeHtml(appState.adminOverridePolicy?.current_version ? `版本 ${formatNumber(appState.adminOverridePolicy.current_version.version_no || 0)}` : "未发布")}</span>
|
||
<span class="tag clickable-tag" data-action="open-admin-override-policy">编辑覆盖</span>
|
||
<span class="tag clickable-tag" data-action="open-admin-override-history">历史与回滚</span>
|
||
</div>
|
||
</div>
|
||
<div class="task-item compact" style="margin-top:14px;">
|
||
<h4>治理说明</h4>
|
||
<p>管理员对用户策略的代改不会抹掉用户自己的历史,只会形成更高优先级的覆盖层,并在这里留下审计记录。</p>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>最近策略审计</h3><div class="panel-subtitle">系统默认、用户策略和管理员覆盖都会在这里形成时间线。</div></div></div>
|
||
<div class="list">
|
||
${renderPolicyAuditFeed(audits, "当前目标还没有策略审计记录。")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderPlatformAgentPanel() {
|
||
const items = safeArray(appState.platformAgents);
|
||
if (!items.length) {
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>平台 Agent</h3>
|
||
<div class="panel-subtitle">当前项目还没有平台 Agent 配置,先从最常用的平台补起。</div>
|
||
</div>
|
||
</div>
|
||
<div class="three-col">
|
||
${ACTIVE_PLATFORMS.map((platformItem) => `
|
||
<div class="entity-card pad">
|
||
<div class="cell-title">${escapeHtml(platformItem.label)} Agent</div>
|
||
<div class="cell-desc">当前还没有单独配置,主 Agent 会先沿用系统默认平台方法论。</div>
|
||
<div class="entity-meta">
|
||
<span class="tag blue">未单独配置</span>
|
||
<span class="tag clickable-tag" data-action="open-platform-agent-profile" data-platform="${escapeHtml(platformItem.value)}">开始配置</span>
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>平台 Agent</h3>
|
||
<div class="panel-subtitle">按用户 + 平台隔离,沉淀该平台的方法论、记忆和技能。</div>
|
||
</div>
|
||
<span class="tag blue">${escapeHtml(formatNumber(items.length))} 个</span>
|
||
</div>
|
||
<div class="three-col">
|
||
${items.map((item) => `
|
||
<div class="entity-card pad">
|
||
<div class="cell-title">${escapeHtml(item.name || item.platform_label)}</div>
|
||
<div class="cell-desc">${escapeHtml(item.mission || item.notes || "先绑定执行 Agent,再补任务目标和方法论。")}</div>
|
||
<div class="entity-meta">
|
||
<span class="tag ${item.status === "active" ? "green" : "blue"}">${escapeHtml(item.status || "draft")}</span>
|
||
${item.readiness_label ? `<span class="tag ${item.readiness_score >= 75 ? "green" : item.readiness_score >= 50 ? "blue" : "orange"}">${escapeHtml(item.readiness_label)} ${escapeHtml(formatNumber(item.readiness_score || 0))}</span>` : ""}
|
||
<span class="tag">记忆 ${escapeHtml(formatNumber(item.memory_count))}</span>
|
||
<span class="tag">技能 ${escapeHtml(formatNumber(item.skill_count))}</span>
|
||
<span class="tag">${escapeHtml(item.assistant?.name || "未绑 Agent")}</span>
|
||
</div>
|
||
${item.recent_memory || item.recent_skill ? `
|
||
<div class="list" style="margin-top:10px;">
|
||
${item.recent_memory ? `
|
||
<div class="task-item compact">
|
||
<h4>最近记忆 · ${escapeHtml(item.recent_memory.title || item.recent_memory.memory_key || "未命名")}</h4>
|
||
<p>${escapeHtml(brief(item.recent_memory.summary || "暂无摘要", 68))}</p>
|
||
</div>
|
||
` : ""}
|
||
${item.recent_skill ? `
|
||
<div class="task-item compact">
|
||
<h4>最近技能 · ${escapeHtml(item.recent_skill.name || item.recent_skill.skill_key || "未命名")}</h4>
|
||
<p>${escapeHtml(brief(item.recent_skill.test_spec?.summary || item.recent_skill.method?.summary || "暂无方法摘要", 68))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(item.recent_skill.status || "draft")}</span>
|
||
<span class="tag blue">得分 ${escapeHtml(formatNumber(item.recent_skill.last_score || 0))}</span>
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
` : ""}
|
||
${item.recent_execution?.run_id ? `
|
||
<div class="task-item compact" style="margin-top:10px;">
|
||
<h4>最近执行</h4>
|
||
<p>${escapeHtml(item.recent_execution.summary || "最近一次主 Agent 执行已回写到当前平台 Agent。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(item.recent_execution.intent_label || "主 Agent 任务")}</span>
|
||
<span class="tag">${escapeHtml(item.recent_execution.run_status || "done")}</span>
|
||
${item.recent_execution.workstream_label ? `<span class="tag green">${escapeHtml(item.recent_execution.workstream_label)}</span>` : ""}
|
||
${item.recent_execution.oneliner_profile_version_no ? `<span class="tag">配置 v${escapeHtml(formatNumber(item.recent_execution.oneliner_profile_version_no))}</span>` : ""}
|
||
${item.recent_execution.platform_agent_profile_version_no ? `<span class="tag">${escapeHtml(item.platform_label || platformLabel(item.platform))} Agent v${escapeHtml(formatNumber(item.recent_execution.platform_agent_profile_version_no))}</span>` : ""}
|
||
${item.recent_execution.source_screen ? `<span class="tag">${escapeHtml(screenLabel(item.recent_execution.source_screen) || item.recent_execution.source_screen)}</span>` : ""}
|
||
</div>
|
||
<div class="task-meta" style="margin-top:8px;">
|
||
${item.recent_execution.recommended_action?.action ? `<span class="tag clickable-tag" data-action="${escapeHtml(item.recent_execution.recommended_action.action)}" ${buildMainAgentLandingAttrs({ runId: item.recent_execution.run_id, screen: item.recent_execution.recommended_action.screen || item.recent_execution.source_screen, title: item.recent_execution.recommended_action.label || "回到业务页", summary: item.recent_execution.recommended_action.summary || item.recent_execution.summary || "" })}>${escapeHtml(item.recent_execution.recommended_action.label || "回到业务页")}</span>` : ""}
|
||
<span class="tag clickable-tag" data-action="open-oneliner-run-result" data-run-id="${escapeHtml(item.recent_execution.run_id)}">查看执行结果</span>
|
||
<span class="tag clickable-tag" data-action="open-oneliner-run-context" data-run-id="${escapeHtml(item.recent_execution.run_id)}">回到主 Agent 查看</span>
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
<span class="tag clickable-tag" data-action="open-platform-agent-detail" data-platform="${escapeHtml(item.platform)}">查看详情</span>
|
||
<span class="tag clickable-tag" data-action="open-platform-agent-profile" data-platform="${escapeHtml(item.platform)}">配置</span>
|
||
<span class="tag clickable-tag" data-action="open-platform-agent-profile-history" data-platform="${escapeHtml(item.platform)}">看配置历史</span>
|
||
<span class="tag clickable-tag" data-action="open-platform-agent-memory" data-platform="${escapeHtml(item.platform)}">补记忆</span>
|
||
<span class="tag clickable-tag" data-action="open-platform-agent-skill" data-platform="${escapeHtml(item.platform)}">补技能</span>
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderAdminOpsPanel() {
|
||
if (!isSuperAdmin()) return "";
|
||
const overview = appState.adminOpsOverview;
|
||
if (!overview) {
|
||
return `
|
||
<div class="panel pad" style="margin-top:18px;">
|
||
<div class="panel-head"><div><h3>运维与审计 Agent</h3><div class="panel-subtitle">仅平台最高权限用户可见。</div></div></div>
|
||
<div class="task-item"><h4>尚未拉到概览</h4><p>刷新后会自动读取失败任务、集成健康和待审事件。</p></div>
|
||
${renderAdminFixRunsPanel()}
|
||
${renderRecoveryHistoryPanel()}
|
||
</div>
|
||
`;
|
||
}
|
||
const incidents = safeArray(overview.incidents).slice(0, 6);
|
||
const audits = safeArray(overview.recent_audits).slice(0, 5);
|
||
return `
|
||
<div class="panel pad" style="margin-top:18px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>运维与审计 Agent</h3>
|
||
<div class="panel-subtitle">只给管理员开放,主要盯日志、失败任务和集成异常。</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(formatNumber(overview.incident_count))} 条事件</span>
|
||
<span class="tag orange">待处理 ${escapeHtml(formatNumber(overview.open_incident_count || 0))}</span>
|
||
<span class="tag red">错误 ${escapeHtml(formatNumber(overview.severity_counts?.error || 0))}</span>
|
||
<span class="tag">${escapeHtml(formatNumber(overview.failed_job_count))} 个失败任务</span>
|
||
<span class="tag">修复计划 ${escapeHtml(formatNumber(overview.fix_run_count || 0))}</span>
|
||
<span class="tag clickable-tag" data-action="scan-admin-ops">重新扫描</span>
|
||
</div>
|
||
</div>
|
||
<div class="list">
|
||
${incidents.map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.title)}</h4>
|
||
<p>${escapeHtml(item.summary || "待补详情")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${item.severity === "error" ? "red" : "orange"}">${escapeHtml(item.severity || "warn")}</span>
|
||
<span class="tag">${escapeHtml(item.status || "open")}</span>
|
||
${item.source_type ? `<span class="tag">${escapeHtml(item.source_type)}</span>` : ""}
|
||
${item.tenant_user_id ? `<span class="tag">租户 ${escapeHtml(brief(item.tenant_user_id, 12))}</span>` : ""}
|
||
${item.source_type === "job" ? actionTag("看任务详情", "open-job-detail", `data-job-id="${escapeHtml(item.source_id || "")}"`) : ""}
|
||
${item.source_type === "integration" ? actionTag("去自动流程", "goto-automation") : ""}
|
||
${item.tenant_project_id ? actionTag("去生产中心", "goto-production") : ""}
|
||
<span class="tag clickable-tag" data-action="open-admin-repair-plan" data-incident-id="${escapeHtml(item.id)}">生成修复计划</span>
|
||
<span class="tag clickable-tag" data-action="review-admin-incident" data-incident-id="${escapeHtml(item.id)}">审计处理</span>
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item"><h4>当前没有待处理事件</h4><p>最近主链比较稳定,继续观察即可。</p></div>`}
|
||
</div>
|
||
<div class="list" style="margin-top:14px;">
|
||
<div class="task-item compact">
|
||
<h4>最近审计记录</h4>
|
||
<p>保留管理员扫描、放行、驳回等动作,方便商业化量产时追责和复盘。</p>
|
||
</div>
|
||
${audits.map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.summary || item.action_key || "审计记录")}</h4>
|
||
<p>${escapeHtml(formatDateTime(item.created_at))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(item.action_key || "audit")}</span>
|
||
<span class="tag">${escapeHtml(item.status || "recorded")}</span>
|
||
${item.incident_id ? `<span class="tag">事件 ${escapeHtml(brief(item.incident_id, 10))}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>还没有审计记录</h4><p>等管理员做一次扫描或审计处理后,这里会自动出现。</p></div>`}
|
||
</div>
|
||
${renderAdminFixRunsPanel()}
|
||
${renderRecoveryHistoryPanel()}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function getIntegrationOverview() {
|
||
const cards = getIntegrationCards();
|
||
const reachableCount = cards.filter((item) => item.detail.available && item.detail.reachable).length;
|
||
const availableCount = cards.filter((item) => item.detail.available).length;
|
||
const aiVideoGuard = getPipelineGuard("aiVideo");
|
||
const realCutGuard = getPipelineGuard("realCut");
|
||
const blockedActions = [
|
||
!aiVideoGuard.enabled ? aiVideoGuard.reason : "",
|
||
!realCutGuard.enabled ? realCutGuard.reason : ""
|
||
].filter(Boolean);
|
||
const tone = !availableCount
|
||
? "blue"
|
||
: blockedActions.length
|
||
? "red"
|
||
: cards.some((item) => item.detail.available && !item.detail.reachable)
|
||
? "orange"
|
||
: "green";
|
||
const headline = !availableCount
|
||
? "依赖健康尚未拉取"
|
||
: blockedActions.length
|
||
? `自动链路受阻:${blockedActions.length} 项`
|
||
: `${reachableCount}/${cards.length} 项依赖在线`;
|
||
const subtitle = !availableCount
|
||
? "刷新后会显示直播录制 / cutvideo / huobao / n8n / ASR 的真实状态。"
|
||
: blockedActions.length
|
||
? blockedActions.join(";")
|
||
: "AI 视频与实拍剪辑链路当前可直接发起。";
|
||
return { cards, tone, headline, subtitle };
|
||
}
|
||
|
||
function getJobSeedBrief(job) {
|
||
return [
|
||
job?.style_summary,
|
||
job?.transcript_text,
|
||
job?.result?.summary,
|
||
job?.artifacts?.brief,
|
||
job?.title
|
||
].find((value) => String(value || "").trim()) || "";
|
||
}
|
||
|
||
function collectHttpLinks(input, path = "result", bucket = []) {
|
||
if (!input) return bucket;
|
||
if (typeof input === "string") {
|
||
const value = input.trim();
|
||
if (/^https?:\/\//i.test(value)) {
|
||
bucket.push({ label: path, url: value });
|
||
}
|
||
return bucket;
|
||
}
|
||
if (Array.isArray(input)) {
|
||
input.forEach((item, index) => collectHttpLinks(item, `${path}[${index}]`, bucket));
|
||
return bucket;
|
||
}
|
||
if (typeof input === "object") {
|
||
Object.entries(input).forEach(([key, value]) => collectHttpLinks(value, `${path}.${key}`, bucket));
|
||
}
|
||
return bucket;
|
||
}
|
||
|
||
function getJobPreviewLinks(job) {
|
||
const deduped = [];
|
||
const seen = new Set();
|
||
collectHttpLinks(job?.result, "result", deduped);
|
||
collectHttpLinks(job?.artifacts, "artifacts", deduped);
|
||
return deduped.filter((item) => {
|
||
if (!item.url || seen.has(item.url)) return false;
|
||
seen.add(item.url);
|
||
return true;
|
||
}).slice(0, 8);
|
||
}
|
||
|
||
function isCandidateLinked(candidate, links) {
|
||
const accountId = String(candidate?.candidate_account_id || "");
|
||
const profileUrl = String(candidate?.candidate_profile_url || "");
|
||
return safeArray(links).some((link) => (
|
||
(accountId && String(link.target_account_id || "") === accountId) ||
|
||
(profileUrl && String(link.target_profile_url || "") === profileUrl)
|
||
));
|
||
}
|
||
|
||
function markSavedCandidate(candidate, links) {
|
||
const nextCandidates = safeArray(appState.lastSimilaritySearch?.candidates).map((item) => {
|
||
const sameAccount = String(item.candidate_account_id || "") && String(item.candidate_account_id || "") === String(candidate.candidate_account_id || "");
|
||
const sameUrl = String(item.candidate_profile_url || "") && String(item.candidate_profile_url || "") === String(candidate.candidate_profile_url || "");
|
||
if (!sameAccount && !sameUrl) return item;
|
||
return { ...item, saved: true };
|
||
});
|
||
if (appState.lastSimilaritySearch) {
|
||
appState.lastSimilaritySearch = {
|
||
...appState.lastSimilaritySearch,
|
||
candidates: nextCandidates
|
||
};
|
||
}
|
||
if (appState.selectedWorkspace) {
|
||
appState.selectedWorkspace = {
|
||
...appState.selectedWorkspace,
|
||
linked_accounts: safeArray(links)
|
||
};
|
||
}
|
||
}
|
||
|
||
async function saveCandidateAsBenchmark(candidateIndex, relationType = "benchmark") {
|
||
const account = requireSelectedAccountRow();
|
||
const platform = getAccountPlatform(account);
|
||
const benchmarkPath = getWorkbenchRoute(platform, "benchmarkLinks", account.id);
|
||
const candidate = safeArray(appState.lastSimilaritySearch?.candidates)[Number(candidateIndex)];
|
||
if (!candidate) throw new Error("当前候选不存在,请先重新查相似");
|
||
const payload = {
|
||
target_account_ids: candidate.candidate_account_id ? [candidate.candidate_account_id] : [],
|
||
target_profile_urls: candidate.candidate_account_id ? [] : [candidate.candidate_profile_url].filter(Boolean),
|
||
relation_type: relationType,
|
||
note: brief(candidate.rationale_text || "由相似搜索自动加入对标库", 120),
|
||
search_id: appState.lastSimilaritySearch?.id || ""
|
||
};
|
||
if (!payload.target_account_ids.length && !payload.target_profile_urls.length) {
|
||
throw new Error("当前候选没有可保存的账号或主页链接");
|
||
}
|
||
const result = await storyforgeFetch(benchmarkPath, {
|
||
method: "POST",
|
||
body: payload
|
||
});
|
||
markSavedCandidate(candidate, result.links);
|
||
rememberAction("候选已存对标", `已把「${candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号"}」加入对标关系。`, "green", result);
|
||
focusDiscoveryRelations();
|
||
}
|
||
|
||
function screenShell(title, subtitle, actionsHtml, bodyHtml) {
|
||
const actionLayout = splitPrimaryAction(actionsHtml);
|
||
const body = String(bodyHtml || "");
|
||
const hasMobileFocusCard = body.includes("mobile-flow-focus-card");
|
||
const headClassName = ["screen-head"];
|
||
if (hasMobileFocusCard) headClassName.push("screen-head-has-mobile-focus");
|
||
return `
|
||
<div class="${headClassName.join(" ")}">
|
||
<div>
|
||
<h2>${escapeHtml(title)}</h2>
|
||
<p>${escapeHtml(subtitle)}</p>
|
||
</div>
|
||
<div class="action-row">
|
||
${actionLayout.primary ? `<div class="action-row-primary">${actionLayout.primary}</div>` : ""}
|
||
${actionLayout.secondary ? `<div class="action-row-secondary">${actionLayout.secondary}</div>` : ""}
|
||
</div>
|
||
</div>
|
||
${body}
|
||
`;
|
||
}
|
||
|
||
function splitPrimaryAction(actionsHtml) {
|
||
const source = String(actionsHtml || "").trim();
|
||
if (!source) return { primary: "", secondary: "" };
|
||
const actions = source
|
||
.split(/(?=<button\b)/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
if (!actions.length) return { primary: source, secondary: "" };
|
||
return {
|
||
primary: actions[0],
|
||
secondary: actions.slice(1).join("")
|
||
};
|
||
}
|
||
|
||
function button(label, action, tone = "secondary", options = {}) {
|
||
const classes = ["btn", `btn-${tone}`];
|
||
if (options.className) classes.push(options.className);
|
||
if (options.disabledReason) classes.push("is-disabled");
|
||
const targetAction = options.disabledReason ? "show-disabled-reason" : action;
|
||
const title = options.disabledReason || options.title || "";
|
||
const attrs = options.attrs || "";
|
||
return `
|
||
<button
|
||
class="${classes.join(" ")}"
|
||
type="button"
|
||
data-action="${escapeHtml(targetAction)}"
|
||
${options.disabledReason ? `data-disabled-reason="${escapeHtml(options.disabledReason)}" aria-disabled="true"` : ""}
|
||
${title ? `title="${escapeHtml(title)}"` : ""}
|
||
${attrs}
|
||
>${escapeHtml(label)}</button>
|
||
`.replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function actionTag(label, action, attrs = "", options = {}) {
|
||
const classes = ["tag"];
|
||
const targetAction = options.disabledReason ? "show-disabled-reason" : action;
|
||
if (options.disabledReason) {
|
||
classes.push("tag-disabled");
|
||
} else if (targetAction) {
|
||
classes.push("clickable-tag");
|
||
}
|
||
const title = options.disabledReason || options.title || "";
|
||
if (targetAction) {
|
||
return `
|
||
<button
|
||
class="${classes.join(" ")}"
|
||
type="button"
|
||
data-action="${escapeHtml(targetAction)}"
|
||
${options.disabledReason ? `data-disabled-reason="${escapeHtml(options.disabledReason)}" aria-disabled="true"` : ""}
|
||
${title ? `title="${escapeHtml(title)}"` : ""}
|
||
${attrs}
|
||
>${escapeHtml(label)}</button>
|
||
`.replace(/\s+/g, " ").trim();
|
||
}
|
||
return `
|
||
<span
|
||
class="${classes.join(" ")}"
|
||
${title ? `title="${escapeHtml(title)}"` : ""}
|
||
${attrs}
|
||
>${escapeHtml(label)}</span>
|
||
`.replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function renderPipelineButton(kind, tone = "secondary") {
|
||
const config = PIPELINE_GUARDS[kind];
|
||
if (!config) return "";
|
||
const guard = getPipelineGuard(kind);
|
||
const quotaGuard = kind === "aiVideo"
|
||
? getQuotaSummaryForCategory("ai_video")
|
||
: kind === "realCut"
|
||
? getQuotaSummaryForCategory("real_cut")
|
||
: { blocked: false, reason: "" };
|
||
return button(config.label, config.openAction, tone, {
|
||
disabledReason: guard.enabled ? (quotaGuard.blocked ? quotaGuard.reason : "") : guard.reason
|
||
});
|
||
}
|
||
|
||
function renderPipelineJobTag(kind, job, label) {
|
||
const config = PIPELINE_GUARDS[kind];
|
||
if (!config || !job?.id) return "";
|
||
const guard = getPipelineGuard(kind);
|
||
const quotaGuard = kind === "aiVideo"
|
||
? getQuotaSummaryForCategory("ai_video")
|
||
: kind === "realCut"
|
||
? getQuotaSummaryForCategory("real_cut")
|
||
: { blocked: false, reason: "" };
|
||
return actionTag(label, config.jobAction, `data-job-id="${escapeHtml(job.id)}"`, {
|
||
disabledReason: guard.enabled ? (quotaGuard.blocked ? quotaGuard.reason : "") : guard.reason
|
||
});
|
||
}
|
||
|
||
function renderIntegrationOverviewPanel(options = {}) {
|
||
const overview = getIntegrationOverview();
|
||
const cards = overview.cards;
|
||
const showActions = options.showActions !== false;
|
||
return `
|
||
<div class="panel pad integration-panel ${options.compact ? "integration-panel-compact" : ""}">
|
||
<div class="integration-summary ${overview.tone}">
|
||
<div>
|
||
<strong>${escapeHtml(overview.headline)}</strong>
|
||
<p>${escapeHtml(overview.subtitle)}</p>
|
||
</div>
|
||
${showActions ? `
|
||
<div class="integration-actions">
|
||
${renderPipelineButton("aiVideo", "primary")}
|
||
${renderPipelineButton("realCut")}
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
<div class="task-meta integration-highlights">
|
||
${cards.map((item) => `<span class="tag ${item.status.tone}">${escapeHtml(item.meta.label)} ${escapeHtml(item.status.summary)}</span>`).join("")}
|
||
</div>
|
||
<div class="layout-grid grid-4 integration-grid">
|
||
${cards.map((item) => `
|
||
<div class="integration-card ${item.status.tone}">
|
||
<div class="integration-card-head">
|
||
<div>
|
||
<h4>${escapeHtml(item.meta.label)}</h4>
|
||
<p>${escapeHtml(item.meta.hint)}</p>
|
||
</div>
|
||
<span class="tag ${item.status.tone}">${escapeHtml(item.status.summary)}</span>
|
||
</div>
|
||
<div class="task-meta">
|
||
${safeArray(item.meta.impacts).map((impact) => `<span class="tag">${escapeHtml(impact)}</span>`).join("")}
|
||
${item.detail.statusCode ? `<span class="tag">HTTP ${escapeHtml(item.detail.statusCode)}</span>` : ""}
|
||
</div>
|
||
<div class="integration-note">${escapeHtml(item.note)}</div>
|
||
${item.extra ? `<div class="integration-note">${escapeHtml(item.extra)}</div>` : ""}
|
||
<div class="integration-url">${escapeHtml(item.key === "live_recorder" ? "仅通过当前租户的后端代理访问" : (item.key === "cutvideo" ? `${getCutvideoIntegrationUrlLabel(item.detail)}:${item.detail.url || item.detail.baseUrl || "未提供探测地址"}` : (item.detail.url || item.detail.baseUrl || "未提供探测地址")))}</div>
|
||
${item.actions ? `<div class="task-meta integration-highlights" style="margin-top:12px;">${item.actions}</div>` : ""}
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function buildMainAgentHandoffAttrs({
|
||
sourceScreen = "",
|
||
sourceActionKey = "",
|
||
intentKey = "custom",
|
||
title = "",
|
||
goal = "",
|
||
summary = "",
|
||
platform = "",
|
||
platformScope = "single_platform",
|
||
planSteps = []
|
||
} = {}) {
|
||
const attrs = [
|
||
`data-source-screen="${escapeHtml(sourceScreen || appState.screen || "dashboard")}"`,
|
||
`data-source-action-key="${escapeHtml(sourceActionKey || "main-agent-handoff")}"`,
|
||
`data-intent-key="${escapeHtml(intentKey || "custom")}"`,
|
||
`data-title="${escapeHtml(title || goal || "交给主 Agent 处理")}"`,
|
||
`data-goal="${escapeHtml(goal || title || "交给主 Agent 处理")}"`,
|
||
`data-summary="${escapeHtml(summary || "")}"`,
|
||
`data-platform-scope="${escapeHtml(platformScope || "single_platform")}"`
|
||
];
|
||
if (platform) {
|
||
attrs.push(`data-platform="${escapeHtml(platform)}"`);
|
||
}
|
||
if (safeArray(planSteps).length) {
|
||
attrs.push(`data-plan-steps="${escapeHtml(JSON.stringify(safeArray(planSteps)))}"`);
|
||
}
|
||
return attrs.join(" ");
|
||
}
|
||
|
||
function buildMainAgentLandingAttrs({
|
||
runId = "",
|
||
screen = "",
|
||
title = "",
|
||
summary = ""
|
||
} = {}) {
|
||
const attrs = [];
|
||
if (runId) attrs.push(`data-main-agent-run-id="${escapeHtml(runId)}"`);
|
||
if (screen) attrs.push(`data-main-agent-screen="${escapeHtml(screen)}"`);
|
||
if (title) attrs.push(`data-main-agent-title="${escapeHtml(title)}"`);
|
||
if (summary) attrs.push(`data-main-agent-summary="${escapeHtml(summary)}"`);
|
||
return attrs.join(" ");
|
||
}
|
||
|
||
function resolveMainAgentLandingScreen(target) {
|
||
const value = String(target || "").trim();
|
||
if (!value) return "";
|
||
if (!value.startsWith("goto-")) return value;
|
||
const routeMap = {
|
||
"goto-discovery": "discovery",
|
||
"goto-intake": "intake",
|
||
"goto-automation": "automation",
|
||
"goto-playbook": "playbook",
|
||
"goto-tracking": "tracking",
|
||
"goto-production": "production",
|
||
"goto-strategy": "strategy",
|
||
"goto-review": "review"
|
||
};
|
||
return routeMap[value] || value.replace(/^goto-/, "");
|
||
}
|
||
|
||
function captureMainAgentLandingContext(action, targetScreen) {
|
||
const runId = String(action?.dataset?.mainAgentRunId || action?.dataset?.runId || "").trim();
|
||
const screen = resolveMainAgentLandingScreen(action?.dataset?.mainAgentScreen || targetScreen || "");
|
||
const title = String(action?.dataset?.mainAgentTitle || "").trim();
|
||
const summary = String(action?.dataset?.mainAgentSummary || "").trim();
|
||
if (!screen || (!runId && !title && !summary)) {
|
||
return;
|
||
}
|
||
appState.mainAgentLanding = {
|
||
screen,
|
||
runId,
|
||
title,
|
||
summary,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
}
|
||
|
||
function renderMainAgentLandingQuickActions(screenKey) {
|
||
const actionMap = {
|
||
intake: [
|
||
{ action: "create-project", label: "新建项目" },
|
||
{ action: "open-import-video-link", label: "导入作品" },
|
||
{ action: "open-upload-video", label: "上传视频" }
|
||
],
|
||
discovery: [
|
||
{ action: "open-import-homepage", label: "导入主页" },
|
||
{ action: "analyze-top-videos", label: "高分分析" },
|
||
{ action: "goto-tracking", label: "去跟踪账号" }
|
||
],
|
||
tracking: [
|
||
{ action: "refresh-tracking", label: "同步全部" },
|
||
{ action: "mark-tracking-read", label: "标记已读" },
|
||
{ action: "goto-discovery", label: "跳到找对标" }
|
||
],
|
||
automation: [
|
||
{ action: "refresh-data", label: "刷新状态" },
|
||
{ action: "goto-production", label: "去生产中心" }
|
||
],
|
||
playbook: [
|
||
{ action: "open-oneliner-profile", label: "配置 OneLiner" },
|
||
{ action: "open-create-assistant", label: "新建 Agent" },
|
||
{ action: "goto-production", label: "去生产中心" }
|
||
],
|
||
production: [
|
||
{ action: "goto-review", label: "去复盘" },
|
||
{ action: "batch-recover-jobs", label: "批量恢复" },
|
||
{ action: "refresh-data", label: "刷新队列" }
|
||
],
|
||
review: [
|
||
{ action: "open-create-review", label: "写复盘" },
|
||
{ action: "goto-production", label: "去生产中心" },
|
||
{ action: "refresh-data", label: "刷新结果" }
|
||
],
|
||
strategy: [
|
||
{ action: "open-user-global-policy", label: "编辑全局策略" },
|
||
{ action: "open-user-platform-policy", label: "编辑当前平台策略" },
|
||
{ action: "open-oneliner-profile", label: "调整 OneLiner" }
|
||
]
|
||
};
|
||
const actions = safeArray(actionMap[screenKey]).slice(0, 3);
|
||
if (!actions.length) return "";
|
||
return `
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
${actions.map((item) => `<span class="tag clickable-tag" data-action="${escapeHtml(item.action)}">${escapeHtml(item.label)}</span>`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderMainAgentLandingNotice(screenKey) {
|
||
const landing = appState.mainAgentLanding;
|
||
if (!landing || landing.screen !== screenKey) return "";
|
||
const landingRun = safeArray(appState.onelinerRuns).find((item) => item.id === landing.runId);
|
||
const resultSections = landingRun?.result?.result_sections && typeof landingRun.result.result_sections === "object"
|
||
? landingRun.result.result_sections
|
||
: {};
|
||
const cards = safeArray(resultSections.cards).slice(0, 2);
|
||
const recommendedAction = landingRun?.result?.recommended_action || null;
|
||
return `
|
||
<div class="task-item compact" style="margin-bottom:18px; border-color:rgba(59, 130, 246, 0.24); background:linear-gradient(180deg, rgba(239, 246, 255, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);">
|
||
<div class="mobile-only compact-summary-row">
|
||
<span>主 Agent 结果</span>
|
||
<strong>${escapeHtml(resultSections.workstream_label || recommendedAction?.label || landing.title || "继续处理")}</strong>
|
||
<span>${escapeHtml(landing.runId ? "可继续处理" : "已生成提示")}</span>
|
||
<strong>${escapeHtml(recommendedAction?.label || "继续处理")}</strong>
|
||
</div>
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>主 Agent 结果</strong>
|
||
<span class="tag blue">${escapeHtml(resultSections.workstream_label || "继续处理")}</span>
|
||
</div>
|
||
<p>${escapeHtml(landing.summary || landing.title || "这是主 Agent 刚刚给出的下一步落点。")}</p>
|
||
<div class="task-meta">
|
||
${landing.runId ? `<span class="tag clickable-tag" data-action="open-oneliner-run-result" data-run-id="${escapeHtml(landing.runId)}">查看结果</span>` : ""}
|
||
<span class="tag clickable-tag" data-action="dismiss-main-agent-landing">收起提示</span>
|
||
</div>
|
||
</div>
|
||
<h4>你正在处理主 Agent 的结果</h4>
|
||
<p>${escapeHtml(landing.summary || landing.title || "这是主 Agent 刚刚给出的下一步落点。")}</p>
|
||
${cards.length ? `
|
||
<div class="detail-grid" style="margin-top:12px;">
|
||
${cards.map((card, index) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(card?.title || (index === 0 ? "当前焦点" : "结果摘要"))}</h4>
|
||
<p>${escapeHtml(card?.body || "主 Agent 已整理出一条可继续推进的建议。")}</p>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
${renderMainAgentLandingQuickActions(screenKey)}
|
||
<div class="task-meta">
|
||
${landing.title ? `<span class="tag blue">${escapeHtml(landing.title)}</span>` : ""}
|
||
${resultSections.workstream_label ? `<span class="tag">${escapeHtml(resultSections.workstream_label)}</span>` : ""}
|
||
${recommendedAction?.label ? `<span class="tag blue">${escapeHtml(recommendedAction.label)}</span>` : ""}
|
||
${landing.runId ? `<span class="tag clickable-tag" data-action="open-oneliner-run-result" data-run-id="${escapeHtml(landing.runId)}">查看结果</span>` : ""}
|
||
<span class="tag clickable-tag" data-action="dismiss-main-agent-landing">收起提示</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderEmptyState(title, description) {
|
||
return `<div class="panel pad"><div class="empty-state"><strong>${escapeHtml(title)}</strong><p>${escapeHtml(description)}</p></div></div>`;
|
||
}
|
||
|
||
function renderPlatformSwitchChips(currentPlatform) {
|
||
return getPlatformOptions().map((item) => `
|
||
<span
|
||
class="chip clickable-tag ${item.value === currentPlatform ? "active" : ""}"
|
||
data-action="select-platform"
|
||
data-platform="${escapeHtml(item.value)}"
|
||
>
|
||
${escapeHtml(getPlatformShortLabel(item.value))}
|
||
</span>
|
||
`).join("");
|
||
}
|
||
|
||
function getProjectNameById(projectId) {
|
||
return safeArray(appState.dashboard?.projects).find((project) => project.id === projectId)?.name || projectId || "-";
|
||
}
|
||
|
||
function formatSnapshotFieldValue(value) {
|
||
if (value == null) return "-";
|
||
const text = typeof value === "string" ? value : JSON.stringify(value);
|
||
return brief(text, 120);
|
||
}
|
||
|
||
function renderSnapshotFieldRows(fields, limit = 8) {
|
||
return safeArray(fields)
|
||
.slice(0, limit)
|
||
.map((field) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(field.field_path || field.path || "field")}</h4>
|
||
<p>${escapeHtml(formatSnapshotFieldValue(field.field_value_text || field.value || field.summary || ""))}</p>
|
||
<div class="task-meta">
|
||
${field.field_type ? `<span class="tag blue">${escapeHtml(field.field_type)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
function renderDouyinInsightPanel() {
|
||
const selected = getSelectedAccount();
|
||
if (!selected || getAccountPlatform(selected) !== "douyin") {
|
||
return "";
|
||
}
|
||
const snapshots = safeArray(appState.snapshots);
|
||
const selectedSnapshot = appState.selectedSnapshotDetail
|
||
|| snapshots.find((item) => item.id === appState.selectedSnapshotId)
|
||
|| null;
|
||
const creatorFields = appState.creatorFields || null;
|
||
const analysisReports = safeArray(appState.analysisReports.length ? appState.analysisReports : appState.selectedWorkspace?.recent_reports);
|
||
const snapshotSummary = selectedSnapshot?.summary || {};
|
||
const creatorSummary = creatorFields?.summary || {};
|
||
const selectedSnapshotFields = safeArray(selectedSnapshot?.fields);
|
||
const creatorSnapshotFields = safeArray(creatorFields?.fields);
|
||
return `
|
||
<div class="panel pad" id="douyin-insight-anchor" style="box-shadow:none; margin-top:16px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>抖音快照详情</h3>
|
||
<div class="panel-subtitle">快照、创作者字段和分析报告统一在这里看</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(formatNumber(snapshots.length))} 个快照</span>
|
||
<span class="tag">${escapeHtml(formatNumber(creatorSnapshotFields.length || creatorFields?.field_count || 0))} 个字段</span>
|
||
<span class="tag green">${escapeHtml(formatNumber(analysisReports.length))} 条报告</span>
|
||
<span class="tag clickable-tag" data-action="refresh-data">刷新</span>
|
||
</div>
|
||
</div>
|
||
<div class="mini-grid">
|
||
<div class="mini-card">
|
||
<small>快照类型</small>
|
||
<strong>${escapeHtml(selectedSnapshot?.snapshot_type || "未选中")}</strong>
|
||
<span>${escapeHtml(selectedSnapshot?.collected_at ? formatDateTime(selectedSnapshot.collected_at) : "等待选择")}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>字段数</small>
|
||
<strong>${escapeHtml(formatNumber(selectedSnapshot?.field_count || 0))}</strong>
|
||
<span>${escapeHtml(selectedSnapshot?.source_url ? brief(selectedSnapshot.source_url, 28) : "暂无来源")}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>创作者字段</small>
|
||
<strong>${escapeHtml(formatNumber(creatorFields?.field_count || 0))}</strong>
|
||
<span>${escapeHtml(creatorFields?.collected_at ? formatDateTime(creatorFields.collected_at) : "尚未拉取")}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>分析报告</small>
|
||
<strong>${escapeHtml(formatNumber(analysisReports.length))}</strong>
|
||
<span>${escapeHtml(analysisReports[0]?.created_at ? formatDateTime(analysisReports[0].created_at) : "暂无报告")}</span>
|
||
</div>
|
||
</div>
|
||
<div class="two-col" style="margin-top:14px;">
|
||
<div class="task-item compact">
|
||
<h4>快照列表</h4>
|
||
<p>点击任意快照可以切换右侧详情,便于比对公开页和 creator center 的变化。</p>
|
||
<div class="list" style="margin-top:10px;">
|
||
${snapshots.map((snapshot) => `
|
||
<div class="task-item compact ${snapshot.id === appState.selectedSnapshotId ? "active" : ""}">
|
||
<h4>${escapeHtml(snapshot.snapshot_type || "snapshot")} · ${escapeHtml(formatDateTime(snapshot.collected_at))}</h4>
|
||
<p>${escapeHtml(brief(JSON.stringify(snapshot.summary || {}), 96))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(formatNumber(snapshot.field_count || 0))} 字段</span>
|
||
<span class="tag clickable-tag" data-action="select-douyin-snapshot" data-snapshot-id="${escapeHtml(snapshot.id)}">查看详情</span>
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>还没有快照</h4><p>同步账号后,这里会自动出现 public profile 和 creator center 快照。</p></div>`}
|
||
</div>
|
||
</div>
|
||
<div class="task-item compact">
|
||
<h4>当前快照详情</h4>
|
||
<p>${escapeHtml(selectedSnapshot ? brief(JSON.stringify(snapshotSummary), 120) : "先从左侧选择一个快照")}</p>
|
||
<div class="task-meta">
|
||
${selectedSnapshot?.source_url ? `<a class="tag" href="${escapeHtml(selectedSnapshot.source_url)}" target="_blank" rel="noreferrer">打开来源</a>` : ""}
|
||
${selectedSnapshot?.snapshot_type ? `<span class="tag blue">${escapeHtml(selectedSnapshot.snapshot_type)}</span>` : ""}
|
||
</div>
|
||
<div class="list" style="margin-top:10px;">
|
||
${selectedSnapshotFields.length ? renderSnapshotFieldRows(selectedSnapshotFields, 6) : `<div class="task-item compact"><h4>暂无字段</h4><p>选中快照后会显示原始字段明细。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="two-col" style="margin-top:14px;">
|
||
<div class="task-item compact">
|
||
<h4>Creator Fields</h4>
|
||
<p>${escapeHtml(creatorFields ? brief(JSON.stringify(creatorSummary), 120) : "尚未拉取 creator center 字段")}</p>
|
||
<div class="task-meta">
|
||
${creatorFields?.source_url ? `<a class="tag" href="${escapeHtml(creatorFields.source_url)}" target="_blank" rel="noreferrer">打开 creator center</a>` : ""}
|
||
${creatorFields?.snapshot_type ? `<span class="tag blue">${escapeHtml(creatorFields.snapshot_type)}</span>` : ""}
|
||
${creatorFields?.field_count != null ? `<span class="tag">${escapeHtml(formatNumber(creatorFields.field_count))} 字段</span>` : ""}
|
||
</div>
|
||
<div class="list" style="margin-top:10px;">
|
||
${creatorSnapshotFields.length ? renderSnapshotFieldRows(creatorSnapshotFields, 6) : `<div class="task-item compact"><h4>还没有 creator 字段</h4><p>等 creator center 快照同步后,这里会展示字段明细。</p></div>`}
|
||
</div>
|
||
</div>
|
||
<div class="task-item compact">
|
||
<h4>分析报告</h4>
|
||
<p>分析报告来自 <code>/analysis-reports</code>,可直接对照结论和建议。</p>
|
||
<div class="list" style="margin-top:10px;">
|
||
${analysisReports.map((report) => {
|
||
const suggestion = safeArray(report.suggestions)[0] || null;
|
||
const summary = suggestion?.parsed_json?.executive_summary || suggestion?.suggestion_text || report.focus_text || "暂无结论";
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(brief(report.focus_text || "分析报告", 34))}</h4>
|
||
<p>${escapeHtml(brief(summary, 120))}</p>
|
||
<div class="task-meta">
|
||
${report.created_at ? `<span class="tag blue">${escapeHtml(formatDateTime(report.created_at))}</span>` : ""}
|
||
${suggestion?.model_label ? `<span class="tag">${escapeHtml(suggestion.model_label)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("") || `<div class="task-item compact"><h4>还没有分析报告</h4><p>对当前账号跑一次分析后,这里会自动出现结论和建议。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function openDouyinSnapshotDetailAction(snapshotId) {
|
||
const selected = getSelectedAccount();
|
||
if (!selected || getAccountPlatform(selected) !== "douyin") {
|
||
return;
|
||
}
|
||
if (!snapshotId) {
|
||
return;
|
||
}
|
||
setBusy(true, "正在加载快照详情...");
|
||
try {
|
||
const detail = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(selected.id)}/snapshots/${encodeURIComponent(snapshotId)}`);
|
||
appState.selectedSnapshotId = snapshotId;
|
||
appState.selectedSnapshotDetail = detail;
|
||
rememberAction("快照已切换", `已打开 ${detail.snapshot_type || "snapshot"} 的完整详情。`, "green", detail);
|
||
renderAll();
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
|
||
function renderLiveRecorderManagementPanel() {
|
||
const sources = safeArray(appState.liveRecorderSources);
|
||
const status = appState.liveRecorderStatus || {};
|
||
const health = getIntegrationDetail("live_recorder");
|
||
const liveRecorderHealth = appState.liveRecorderHealth || {};
|
||
const files = safeArray(appState.liveRecorderFiles);
|
||
const activeItems = safeArray(status.active_recordings);
|
||
const runtimeBits = [
|
||
health.available ? health.reachable ? "在线" : (health.configured ? "不可达" : "未配置") : "未拉取",
|
||
status.running ? `运行中 pid ${status.pid || "-"}` : "未运行",
|
||
`活动录制 ${formatNumber(activeItems.length)}`,
|
||
`最近文件 ${formatNumber(files.length)}`
|
||
];
|
||
const directHealthText = liveRecorderHealth
|
||
? (liveRecorderHealth.ok || String(liveRecorderHealth.status || "").toLowerCase() === "ok"
|
||
? "HTTP 健康:ok"
|
||
: `HTTP 健康:${liveRecorderHealth.status || liveRecorderHealth.message || "异常"}`)
|
||
: "HTTP 健康:未拉取";
|
||
return `
|
||
<div class="panel pad" id="live-recorder-maintenance-anchor" style="box-shadow:none;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>Live Recorder 维护面板</h3>
|
||
<div class="panel-subtitle">编辑录制源、查看健康状态、导入配置和删除源都在这里</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag ${health.reachable ? "green" : health.configured ? "orange" : "red"}">${escapeHtml(health.reachable ? "健康" : health.configured ? "待检查" : "未配置")}</span>
|
||
<span class="tag">${escapeHtml(status.running ? "运行中" : "已停止")}</span>
|
||
<span class="tag clickable-tag" data-action="refresh-data">刷新</span>
|
||
<span class="tag clickable-tag" data-action="open-live-recorder-create">新增录制源</span>
|
||
<span class="tag clickable-tag" data-action="import-live-recorder-config">导入 URL 配置</span>
|
||
</div>
|
||
</div>
|
||
<div class="mini-grid">
|
||
${runtimeBits.map((item, index) => `
|
||
<div class="mini-card">
|
||
<small>${escapeHtml(["健康", "运行", "活动", "文件"][index])}</small>
|
||
<strong>${escapeHtml(item)}</strong>
|
||
<span>${escapeHtml(index === 0 ? (health.url || health.baseUrl || "未拉取健康数据") : index === 1 ? (status.started_at ? formatDateTime(status.started_at) : "暂无启动时间") : index === 2 ? "当前租户录制状态" : "当前租户录像索引")}</span>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
<div class="task-item compact" style="margin-top:14px;">
|
||
<h4>直连健康</h4>
|
||
<p>${escapeHtml(directHealthText)}</p>
|
||
<div class="task-meta">
|
||
${liveRecorderHealth?.base_url ? `<span class="tag">${escapeHtml(brief(liveRecorderHealth.base_url, 32))}</span>` : ""}
|
||
${liveRecorderHealth?.url ? `<span class="tag blue">${escapeHtml(brief(liveRecorderHealth.url, 32))}</span>` : ""}
|
||
${liveRecorderHealth?.pid ? `<span class="tag">${escapeHtml(`pid ${liveRecorderHealth.pid}`)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="list" style="margin-top:14px;">
|
||
<div class="task-item compact">
|
||
<h4>录制源列表</h4>
|
||
<p>默认按当前租户筛选,编辑时可改项目、Agent、标题、清晰度和启停状态。</p>
|
||
</div>
|
||
${sources.map((source) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(source.title || source.remote_name || source.source_url || "录制源")}</h4>
|
||
<p>${escapeHtml(source.source_url || "暂无源链接")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(platformLabel(source.platform || "kuaishou"))}</span>
|
||
<span class="tag">${escapeHtml(source.quality || "原画")}</span>
|
||
<span class="tag ${source.enabled ? "green" : "orange"}">${escapeHtml(source.enabled ? "启用" : "停用")}</span>
|
||
<span class="tag">${escapeHtml(getProjectNameById(source.project_id || ""))}</span>
|
||
${source.recording_count ? `<span class="tag blue">${escapeHtml(formatNumber(source.recording_count))} 个活动录制</span>` : ""}
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag clickable-tag" data-action="edit-live-recorder-source" data-source-id="${escapeHtml(source.id)}">编辑</span>
|
||
<span class="tag clickable-tag ${source.enabled ? "orange" : "green"}" data-action="toggle-live-recorder-source" data-source-id="${escapeHtml(source.id)}" data-next-enabled="${escapeHtml(source.enabled ? "false" : "true")}">${escapeHtml(source.enabled ? "停用" : "启用")}</span>
|
||
<span class="tag clickable-tag" data-action="delete-live-recorder-source" data-source-id="${escapeHtml(source.id)}">删除</span>
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>还没有录制源</h4><p>先导入或新增一个直播源,后端会自动同步到租户视图。</p></div>`}
|
||
</div>
|
||
<div class="two-col" style="margin-top:14px;">
|
||
<div class="task-item compact">
|
||
<h4>健康检查与运行状态</h4>
|
||
<p>${escapeHtml([
|
||
health.available ? `健康接口:${health.reachable ? "在线" : "不可达"}` : "还没有拉取健康接口",
|
||
status.url_info?.service_url ? `服务地址:${status.url_info.service_url}` : "",
|
||
activeItems.length ? `活动录制:${activeItems.length}` : "当前没有活动录制"
|
||
].filter(Boolean).join(" · "))}</p>
|
||
<div class="task-meta">
|
||
${status.pid ? `<span class="tag blue">PID ${escapeHtml(status.pid)}</span>` : ""}
|
||
${status.last_exit_code != null ? `<span class="tag">${escapeHtml(`退出码 ${status.last_exit_code}`)}</span>` : ""}
|
||
${status.url_info?.base_url ? `<a class="tag" href="${escapeHtml(status.url_info.base_url)}" target="_blank" rel="noreferrer">打开服务</a>` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="task-item compact">
|
||
<h4>最近文件</h4>
|
||
<p>文件沿用当前租户视图,支持直接打开查看。</p>
|
||
<div class="list" style="margin-top:10px;">
|
||
${files.slice(0, 5).map((file) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(file.title || file.name || file.relative_path || "录像文件")}</h4>
|
||
<p>${escapeHtml(file.relative_path || file.name || file.content_url || "-")}</p>
|
||
<div class="task-meta">
|
||
${file.mtime ? `<span class="tag blue">${escapeHtml(formatDateTime(file.mtime))}</span>` : ""}
|
||
${file.id ? `<span class="tag clickable-tag" data-action="open-live-recorder-file" data-file-id="${escapeHtml(file.id)}">打开文件</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>还没有文件</h4><p>开始录制后,最新文件会出现在这里。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderAdminFixRunsPanel() {
|
||
if (!isSuperAdmin()) return "";
|
||
const overview = appState.adminOpsOverview || {};
|
||
const items = safeArray(appState.adminFixRuns.length ? appState.adminFixRuns : overview.recent_fix_runs);
|
||
if (!items.length) {
|
||
return `
|
||
<div class="panel pad" style="margin-top:14px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>修复计划列表</h3>
|
||
<div class="panel-subtitle">还没有拉到修复计划</div>
|
||
</div>
|
||
</div>
|
||
<div class="task-item"><h4>暂无修复计划</h4><p>生成修复计划后,这里会展示完整的 audit 列表。</p></div>
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div class="panel pad" style="margin-top:14px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>修复计划列表</h3>
|
||
<div class="panel-subtitle">完整展示最近的 fix runs,并支持直接审计</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(formatNumber(items.length))} 条</span>
|
||
<span class="tag">${escapeHtml(formatNumber(items.filter((item) => item.audit_status === "approved").length))} 已通过</span>
|
||
<span class="tag orange">${escapeHtml(formatNumber(items.filter((item) => item.audit_status === "watching").length))} 观察中</span>
|
||
<span class="tag clickable-tag" data-action="refresh-data">刷新</span>
|
||
</div>
|
||
</div>
|
||
<div class="list">
|
||
${items.map((item) => {
|
||
const plan = item.plan || {};
|
||
const verification = item.verification || {};
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(plan.summary || item.id || "修复计划")}</h4>
|
||
<p>${escapeHtml(brief(safeArray(plan.steps).join(";") || verification.summary || "暂无修复步骤", 140))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(item.plan_scope || "plan")}</span>
|
||
<span class="tag ${item.audit_status === "approved" ? "green" : item.audit_status === "rejected" ? "red" : "orange"}">${escapeHtml(item.audit_status || "pending")}</span>
|
||
${item.status ? `<span class="tag">${escapeHtml(item.status)}</span>` : ""}
|
||
${item.incident_id ? `<span class="tag">${escapeHtml(brief(item.incident_id, 12))}</span>` : ""}
|
||
${item.updated_at ? `<span class="tag">${escapeHtml(formatDateTime(item.updated_at))}</span>` : ""}
|
||
<span class="tag clickable-tag" data-action="open-admin-fix-run-detail" data-run-id="${escapeHtml(item.id)}">查看详情</span>
|
||
<span class="tag clickable-tag" data-action="open-admin-fix-run-audit" data-run-id="${escapeHtml(item.id)}">审计放行</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderAdminWorkbenchScreen() {
|
||
if (!isSuperAdmin()) {
|
||
return screenShell(
|
||
"管理员配置台",
|
||
"仅超级管理员可见。",
|
||
"",
|
||
renderEmptyState("无权限", "请使用超级管理员账号访问管理员配置台。")
|
||
);
|
||
}
|
||
const tabs = [
|
||
{ value: "integrations", label: "依赖健康" },
|
||
{ value: "storage", label: "存储状态" },
|
||
{ value: "agents", label: "Agent 治理" },
|
||
{ value: "governance_audit", label: "覆盖与审计" },
|
||
{ value: "ops", label: "运维审计" }
|
||
];
|
||
const activeTab = getActiveDetailTab("adminWorkbenchTab", tabs);
|
||
return screenShell(
|
||
"管理员配置台",
|
||
"系统级依赖、存储、平台 Agent 与运维治理。",
|
||
"",
|
||
`
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>系统治理工作区</h3>
|
||
<div class="panel-subtitle">按系统依赖、存储、Agent 治理和运维审计分区查看,不再整页堆叠。</div>
|
||
</div>
|
||
</div>
|
||
${renderDetailTabs("adminWorkbenchTab", tabs)}
|
||
${activeTab === "integrations"
|
||
? renderIntegrationOverviewPanel({ showActions: false })
|
||
: activeTab === "storage"
|
||
? renderStorageStatusPanel()
|
||
: activeTab === "agents"
|
||
? `${renderAdminGovernanceSummaryPanel()}${renderPlatformAgentPanel()}<div style="margin-top:18px;">${renderOneLinerActionRegistryPanel()}</div>`
|
||
: activeTab === "governance_audit"
|
||
? renderAdminGovernanceAuditPanel()
|
||
: renderAdminOpsPanel()}
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderDashboardScreen() {
|
||
if (!appState.session) {
|
||
if (isAutoConnectionPending()) {
|
||
return renderAutoConnectingScreen("项目总台", "会话签发成功后,这里会自动切成真实项目总台。");
|
||
}
|
||
return screenShell(
|
||
"项目总台",
|
||
"先自动连接工作区,再加载项目、对标、Agent 和生产状态。",
|
||
`${button("自动连接", "open-auth", "primary")}`,
|
||
renderEmptyState("还没有连接 StoryForge", "自动连接成功后,这里会替换成真实项目总台。")
|
||
);
|
||
}
|
||
if (!appState.dashboard) {
|
||
return screenShell(
|
||
"项目总台",
|
||
appState.me?.approval_status === "pending" ? "当前账号还在等待审批。" : "正在加载项目数据。",
|
||
`${button("刷新", "refresh-data")} ${button("切换连接", "open-auth")}`,
|
||
renderEmptyState("工作区暂未就绪", appState.message || "如果账号未审批通过,当前页会先停在待审批状态。")
|
||
);
|
||
}
|
||
const dashboardHomeRenderer = window.StoryForgeDashboardHome;
|
||
const homeModel = buildDashboardHomeModel();
|
||
return screenShell(
|
||
"项目总台",
|
||
"先做最能推进当前项目的一步,再按需看概览。",
|
||
`${button("新建项目", "create-project")} ${button("导入主页", "open-import-homepage")} ${button("创建 Agent", "open-create-assistant", "primary")}`,
|
||
dashboardHomeRenderer?.renderDashboardHome
|
||
? dashboardHomeRenderer.renderDashboardHome(homeModel, { escapeHtml })
|
||
: renderEmptyState("首页模块未加载", "请刷新页面后重试。")
|
||
);
|
||
}
|
||
|
||
function renderProjectsScreen() {
|
||
if (!appState.dashboard) {
|
||
if (isAutoConnectionPending()) {
|
||
return renderAutoConnectingScreen("我的项目", "工作区就绪后,这里会自动加载真实项目和导入队列。");
|
||
}
|
||
return screenShell("我的项目", "先完成工作区自动连接,再加载项目。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("项目未加载", "自动连接成功后,这里会显示真实项目和导入队列。"));
|
||
}
|
||
const projects = safeArray(appState.dashboard.projects);
|
||
const selectedProject = getSelectedProject();
|
||
const intakeHandoffAttrs = buildMainAgentHandoffAttrs({
|
||
sourceScreen: "intake",
|
||
sourceActionKey: "project-intake-handoff",
|
||
intentKey: "project_intake",
|
||
title: selectedProject ? `继续推进项目 ${selectedProject.name}` : "继续梳理项目工作区",
|
||
goal: selectedProject
|
||
? `围绕项目 ${selectedProject.name} 生成下一步推进计划`
|
||
: "根据当前项目列表和导入情况,生成下一步项目推进计划",
|
||
summary: selectedProject
|
||
? "主 Agent 会结合当前项目状态、账号和任务规模,整理下一步动作。"
|
||
: "主 Agent 会先看项目列表和内容导入情况,再整理下一步动作。",
|
||
platform: getPreferredPlatform(),
|
||
platformScope: "single_platform",
|
||
planSteps: [
|
||
"读取当前项目、账号和任务状态",
|
||
"检查是否需要补导入或切换项目",
|
||
"生成下一步项目推进计划"
|
||
]
|
||
});
|
||
return screenShell(
|
||
"我的项目",
|
||
"先建项目,再决定是否绑定自己的账号。",
|
||
`${button("新建项目", "create-project", "primary")} ${button("导入作品", "open-import-video-link")} ${button("导入文本", "open-import-text")} ${button("上传视频", "open-upload-video")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: intakeHandoffAttrs })}`,
|
||
`
|
||
${renderMainAgentLandingNotice("intake")}
|
||
<div class="hero-card mobile-secondary-card" id="review-workspace-anchor">
|
||
<h3>当前项目</h3>
|
||
<p>${escapeHtml(selectedProject?.name || "还没有项目")} · ${escapeHtml(selectedProject?.description || "创建后即可承接对标、Agent 和生产任务。")}</p>
|
||
<div class="chip-row" style="margin-top:14px;">
|
||
${projects.map((project) => `<span class="chip ${project.id === appState.selectedProjectId ? "active" : ""}" data-action="select-project" data-project-id="${escapeHtml(project.id)}">${escapeHtml(project.name)}</span>`).join("") || `<span class="chip active">暂无项目</span>`}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前项目任务</strong>
|
||
<span class="tag blue">${escapeHtml(selectedProject?.name || "未选项目")}</span>
|
||
</div>
|
||
<p>${escapeHtml(
|
||
selectedProject
|
||
? `先围绕 ${selectedProject.name} 决定是继续导入内容、切换项目,还是直接交给主 Agent 推进。`
|
||
: "先创建或切换到一个项目,再继续绑定账号和推进生产。"
|
||
)}</p>
|
||
<div class="task-meta">
|
||
${actionTag(selectedProject ? "导入作品" : "新建项目", selectedProject ? "open-import-video-link" : "create-project")}
|
||
${actionTag("切换项目", "open-dashboard-project-switcher")}
|
||
${actionTag("交给主 Agent", "handoff-to-main-agent", intakeHandoffAttrs)}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-top:14px; margin-bottom:14px;">
|
||
<span class="tag blue">当前项目 ${escapeHtml(selectedProject?.name || "未选项目")}</span>
|
||
<span class="tag">项目 ${escapeHtml(formatNumber(projects.length))}</span>
|
||
<span class="tag green">内容源 ${escapeHtml(formatNumber(appState.contentSources.length))}</span>
|
||
<span class="tag">${escapeHtml(formatNumber(safeArray(appState.dashboard.recent_jobs).length))} 个任务</span>
|
||
</div>
|
||
<div class="layout-grid grid-main" style="margin-top:18px;">
|
||
<div class="side-stack">
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>项目状态</h3><div class="panel-subtitle">真实项目列表</div></div></div>
|
||
<div class="project-status-grid">
|
||
${projects.map((project) => {
|
||
const stats = getProjectStats(project.id);
|
||
return `
|
||
<div class="entity-card pad">
|
||
<div class="cell-title">${escapeHtml(project.name)}</div>
|
||
<div class="cell-desc">${escapeHtml(project.description || "未填写说明")}</div>
|
||
<div class="entity-meta">
|
||
<span class="tag blue">知识库 ${escapeHtml(formatNumber(stats.knowledgeBases.length))}</span>
|
||
<span class="tag">Agent ${escapeHtml(formatNumber(stats.assistants.length))}</span>
|
||
<span class="tag green">任务 ${escapeHtml(formatNumber(stats.jobs.length))}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("") || `<div class="empty-state">当前还没有项目。</div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>最近导入队列</h3><div class="panel-subtitle">按内容源展示</div></div></div>
|
||
<div class="list">
|
||
${safeArray(appState.contentSources).slice(0, 6).map((source) => `
|
||
<div class="review-card">
|
||
<h4>${escapeHtml(source.title || source.handle || source.source_url || source.id)}</h4>
|
||
<p>${escapeHtml(source.platform || source.source_kind || "内容源")} · ${escapeHtml(source.source_url || source.local_path || "暂无链接")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${source.project_id === appState.selectedProjectId ? "green" : "blue"}">${source.project_id === appState.selectedProjectId ? "当前项目" : "其他项目"}</span>
|
||
<span class="tag">${escapeHtml(source.source_kind || "-")}</span>
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="review-card"><h4>还没有导入内容</h4><p>先去“找对标”导入主页、作品或本地视频。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function getActiveDetailTab(stateKey, tabs) {
|
||
const fallback = tabs[0]?.value || "";
|
||
const values = tabs.map((item) => item.value);
|
||
const active = values.includes(appState[stateKey]) ? appState[stateKey] : fallback;
|
||
appState[stateKey] = active;
|
||
return active;
|
||
}
|
||
|
||
function renderDetailTabs(stateKey, tabs) {
|
||
const active = getActiveDetailTab(stateKey, tabs);
|
||
return `
|
||
<div class="tab-row page-detail-tabs">
|
||
${tabs.map((tab) => `
|
||
<button
|
||
class="tab ${tab.value === active ? "active" : ""}"
|
||
type="button"
|
||
aria-pressed="${tab.value === active ? "true" : "false"}"
|
||
data-action="select-page-tab"
|
||
data-page-tab-key="${escapeHtml(stateKey)}"
|
||
data-page-tab-value="${escapeHtml(tab.value)}"
|
||
>
|
||
${escapeHtml(tab.label)}
|
||
</button>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function focusDiscoveryDetailTab(tabValue) {
|
||
appState.discoveryDetailTab = tabValue;
|
||
appState.screen = "discovery";
|
||
renderAll();
|
||
window.requestAnimationFrame(() => {
|
||
document.getElementById("selected-account-anchor")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}
|
||
|
||
function focusDiscoveryInsights() {
|
||
appState.discoveryDetailTab = "snapshots";
|
||
appState.screen = "discovery";
|
||
renderAll();
|
||
window.requestAnimationFrame(() => {
|
||
(document.getElementById("douyin-insight-anchor") || document.getElementById("selected-account-anchor"))
|
||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}
|
||
|
||
function focusDiscoveryTopVideoInsights() {
|
||
appState.discoveryDetailTab = "overview";
|
||
appState.screen = "discovery";
|
||
renderAll();
|
||
window.requestAnimationFrame(() => {
|
||
(document.getElementById("top-video-batch-anchor") || document.getElementById("selected-account-anchor"))
|
||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}
|
||
|
||
function focusDiscoveryRelations() {
|
||
appState.discoveryDetailTab = "relations";
|
||
appState.screen = "discovery";
|
||
renderAll();
|
||
window.requestAnimationFrame(() => {
|
||
(document.getElementById("discovery-relations-anchor") || document.getElementById("selected-account-anchor"))
|
||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}
|
||
|
||
function focusTrackingWorkspace() {
|
||
setScreen("tracking");
|
||
renderAll();
|
||
window.requestAnimationFrame(() => {
|
||
document.querySelector('[data-screen="tracking"] .mobile-flow-focus-card, [data-screen="tracking"] .panel')?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}
|
||
|
||
function focusProductionDetailTab(tabValue) {
|
||
appState.productionDetailTab = tabValue;
|
||
setScreen("production");
|
||
renderAll();
|
||
window.requestAnimationFrame(() => {
|
||
document.querySelector('[data-screen="production"] .mobile-flow-focus-card, [data-screen="production"] .panel')?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}
|
||
|
||
function focusRecentGeneratedCopy() {
|
||
appState.playbookDetailTab = "workspace";
|
||
setScreen("playbook");
|
||
renderAll();
|
||
window.requestAnimationFrame(() => {
|
||
document.getElementById("recent-generated-copy-anchor")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}
|
||
|
||
function focusReviewWorkspace(reviewId = "") {
|
||
appState.reviewFocusId = reviewId || "";
|
||
setScreen("review");
|
||
renderAll();
|
||
window.requestAnimationFrame(() => {
|
||
const selector = reviewId
|
||
? `[data-review-id="${String(reviewId).replaceAll('"', '\\"')}"]`
|
||
: "#review-workspace-anchor";
|
||
(document.querySelector(selector) || document.getElementById("review-workspace-anchor"))
|
||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}
|
||
|
||
function renderDiscoveryOverviewSection({ selected, selectedProject, importedSources, tracked, topVideos, reports, latestVideos, currentPlatformLabel, topVideoBatchResult }) {
|
||
return `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>接入当前项目</h3><div class="panel-subtitle">把当前对标导入到项目,并绑定 Agent 做持续同步</div></div><span class="tag ${importedSources.length ? "green" : "blue"}">${escapeHtml(importedSources.length ? "已接入" : "未接入")}</span></div>
|
||
${selected ? `
|
||
<div class="task-item">
|
||
<h4>${escapeHtml(selectedProject?.name || "未选项目")}</h4>
|
||
<p>${escapeHtml(importedSources.length ? `当前项目已接入 ${formatNumber(importedSources.length)} 个内容源,可继续同步或换 Agent。` : "当前项目还没有接入这个对标账号,可直接导入主页并绑定 Agent。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(selectedProject?.name || "未选项目")}</span>
|
||
<span class="tag">${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")}</span>
|
||
${actionTag(importedSources.length ? "继续同步" : "导入当前对标", "open-import-selected-account")}
|
||
${tracked ? `<span class="tag green">已在跟踪</span>` : actionTag("加入跟踪", "open-track-selected-account")}
|
||
</div>
|
||
</div>
|
||
` : `<div class="task-item"><h4>还没有选中账号</h4><p>先从上方列表选一个对标账号,再决定是否导入到当前项目。</p></div>`}
|
||
</div>
|
||
<div class="three-col">
|
||
<div class="insight-card">
|
||
<h4>账号画像</h4>
|
||
<ul>
|
||
<li>${escapeHtml(selected?.signature || "暂无签名")}</li>
|
||
<li>${escapeHtml("平台:" + currentPlatformLabel)}</li>
|
||
<li>${escapeHtml("标签:" + (safeArray(selected?.tags).slice(0, 4).join(" / ") || "暂无标签"))}</li>
|
||
<li>${escapeHtml("同步状态:" + (selected?.sync_status || "-"))}</li>
|
||
</ul>
|
||
</div>
|
||
<div class="insight-card">
|
||
<h4>高分作品</h4>
|
||
<ul>
|
||
${topVideos.map((video) => `<li>${escapeHtml(describeVideo(video))}</li>`).join("") || "<li>暂无高分作品</li>"}
|
||
</ul>
|
||
</div>
|
||
<div class="insight-card">
|
||
<h4>最近报告</h4>
|
||
<ul>
|
||
${reports.slice(0, 3).map((report) => {
|
||
const suggestion = safeArray(report.suggestions)[0];
|
||
const summary = suggestion?.parsed_json?.executive_summary || suggestion?.suggestion_text || report.focus_text || "暂无结论";
|
||
return `<li>${escapeHtml(brief(summary, 48))}</li>`;
|
||
}).join("") || "<li>暂无分析报告</li>"}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
${topVideoBatchResult ? `
|
||
<div class="panel pad" id="top-video-batch-anchor" style="box-shadow:none; margin-top:16px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>最近高分拆解</h3>
|
||
<div class="panel-subtitle">这批结果已经回流到当前账号页,不会只停留在顶部提醒里。</div>
|
||
</div>
|
||
<span class="tag blue">${escapeHtml(formatNumber(topVideoBatchResult.analyzed_count || safeArray(topVideoBatchResult.items).length))} 条</span>
|
||
</div>
|
||
<div class="list">
|
||
${safeArray(topVideoBatchResult.items).slice(0, 3).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.video_title || "高分作品")}</h4>
|
||
<p>${escapeHtml(item.summary_text || "已完成拆解。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">得分 ${escapeHtml(formatNumber(item.performance_score || 0))}</span>
|
||
${safeArray(item.parsed_json?.borrow_points).slice(0, 2).map((point) => `<span class="tag">${escapeHtml(point)}</span>`).join("")}
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>最新作品</h3><div class="panel-subtitle">优先看近期更新与窗口期题材</div></div><span class="tag">${escapeHtml(formatNumber(latestVideos.length))} 条</span></div>
|
||
<div class="list">
|
||
${latestVideos.map((video) => `
|
||
<div class="task-item">
|
||
<h4>${escapeHtml(describeVideo(video))}</h4>
|
||
<p>发布时间 ${escapeHtml(formatDateTime(video.published_at))} · 播放 ${escapeHtml(formatNumber(video.stats?.play))} · 点赞 ${escapeHtml(formatNumber(video.stats?.like))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(video.content_type || "video")}</span>
|
||
<span class="tag green">得分 ${escapeHtml(formatNumber(video.score?.performance_score || 0))}</span>
|
||
${getVideoLink(video) ? `<a class="tag" href="${escapeHtml(getVideoLink(video))}" target="_blank" rel="noreferrer">打开原作品</a>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item"><h4>还没有最近作品</h4><p>当前账号只同步了基础信息,还没拉到完整作品列表。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDiscoveryRelationsSection(linkedAccounts, similarCandidates) {
|
||
return `
|
||
<div class="layout-grid grid-main" id="discovery-relations-anchor">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>已绑关系</h3><div class="panel-subtitle">当前账号已经保存的对标关系</div></div><span class="tag">${escapeHtml(formatNumber(linkedAccounts.length))} 个</span></div>
|
||
<div class="list">
|
||
${linkedAccounts.map((link) => `
|
||
<div class="task-item">
|
||
<h4>${escapeHtml(link.target_nickname || link.target_profile_url || "未命名对标")}</h4>
|
||
<p>${escapeHtml(link.note || link.target_profile_url || "已保存对标关系")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(link.relation_type || "benchmark")}</span>
|
||
${link.target_account_id ? `<span class="tag clickable-tag" data-action="select-account" data-account-id="${escapeHtml(link.target_account_id)}">看详情</span>` : ""}
|
||
${link.target_profile_url ? `<a class="tag" href="${escapeHtml(link.target_profile_url)}" target="_blank" rel="noreferrer">打开主页</a>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item"><h4>暂无已保存对标</h4><p>当前账号还没有保存过对标关系。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>最近相似候选</h3><div class="panel-subtitle">由 Agent 辅助生成</div></div><span class="tag">${escapeHtml(formatNumber(similarCandidates.length))} 个</span></div>
|
||
<div class="list">
|
||
${similarCandidates.map((candidate, index) => `
|
||
<div class="task-item">
|
||
<h4>${escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号")}</h4>
|
||
<p>${escapeHtml(brief(candidate.rationale_text || "暂无理由", 96))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">启发分 ${escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score || 0))}</span>
|
||
${candidate.candidate_account_id ? `<span class="tag clickable-tag" data-action="select-account" data-account-id="${escapeHtml(candidate.candidate_account_id)}">看详情</span>` : ""}
|
||
${isCandidateLinked(candidate, linkedAccounts) || candidate.saved ? `<span class="tag green">已保存</span>` : `<span class="tag clickable-tag" data-action="save-candidate-benchmark" data-candidate-index="${escapeHtml(index)}">存对标</span>`}
|
||
${candidate.candidate_profile_url ? `<a class="tag" href="${escapeHtml(candidate.candidate_profile_url)}" target="_blank" rel="noreferrer">打开主页</a>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item"><h4>还没有相似候选</h4><p>先点“查相似”,这里会展示最近一轮结果。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDiscoveryScreen() {
|
||
if (!appState.dashboard) {
|
||
if (isAutoConnectionPending()) {
|
||
return renderAutoConnectingScreen("找对标", "工作区就绪后,这里会自动显示当前平台的账号列表和详情。");
|
||
}
|
||
return screenShell("找对标", "完成工作区自动连接后才能加载真实对标账号。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("对标库未加载", "自动连接成功后,这里会显示当前平台的账号列表和详情。"));
|
||
}
|
||
const query = appState.discoveryQuery.toLowerCase();
|
||
const currentPlatform = getCurrentPlatformValue();
|
||
const currentPlatformLabel = getPlatformShortLabel(currentPlatform);
|
||
const accounts = getAccountsForPlatform(currentPlatform).filter((account) => {
|
||
if (!query) return true;
|
||
return [getAccountName(account), account.signature, getAccountProfileUrl(account), getAccountHandle(account), ...safeArray(account.tags), ...safeArray(account.keywords)]
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(query);
|
||
});
|
||
const selected = getSelectedAccount();
|
||
const selectedPlatform = getAccountPlatform(selected);
|
||
const effectivePlatform = selectedPlatform || currentPlatform;
|
||
const reports = safeArray(appState.analysisReports.length ? appState.analysisReports : appState.selectedWorkspace?.recent_reports);
|
||
const linkedAccounts = safeArray(appState.selectedWorkspace?.linked_accounts);
|
||
const videos = safeArray(appState.selectedVideos?.items);
|
||
const fallbackVideos = safeArray(selected?.video_summary?.videos);
|
||
const effectiveVideos = videos.length ? videos : fallbackVideos;
|
||
const topVideos = getHighScoreVideos(3);
|
||
const latestVideos = getLatestVideos(2);
|
||
const similarCandidates = safeArray(appState.lastSimilaritySearch?.candidates).slice(0, 5);
|
||
const selectedProject = getSelectedProject();
|
||
const importedSources = getCurrentProjectSourcesForAccount(selected, selectedProject?.id || "");
|
||
const tracked = selected?.id ? isTrackedAccount(selected.id) : false;
|
||
const topVideoBatchResult = getSelectedTopVideoAnalysisResult();
|
||
const isMobileUi = isMobileViewport();
|
||
const discoveryHandoffAttrs = buildMainAgentHandoffAttrs({
|
||
sourceScreen: "discovery",
|
||
sourceActionKey: "discovery-handoff",
|
||
intentKey: "benchmark_discovery",
|
||
title: selected ? `继续处理对标 ${getAccountName(selected)}` : "继续推进对标工作",
|
||
goal: selected
|
||
? `围绕 ${getAccountName(selected)} 输出一版下一步对标计划`
|
||
: `根据当前${currentPlatformLabel}账号列表,生成下一步对标推进计划`,
|
||
summary: selected
|
||
? "结合当前选中的对标账号、分析报告和相似关系,生成下一步动作。"
|
||
: "结合当前账号池和已导入内容,整理一版可执行的对标计划。",
|
||
platform: effectivePlatform || currentPlatform,
|
||
platformScope: "single_platform",
|
||
planSteps: [
|
||
"读取当前对标账号与已导入内容",
|
||
"检查分析报告与相似关系",
|
||
"生成下一步对标推进计划"
|
||
]
|
||
});
|
||
const detailTabs = [
|
||
{ value: "overview", label: "账号概览" },
|
||
{ value: "snapshots", label: "快照 / 字段 / 报告" },
|
||
{ value: "relations", label: "相似对标 / 已绑关系" }
|
||
];
|
||
const activeTab = getActiveDetailTab("discoveryDetailTab", detailTabs);
|
||
const selectedSummaryHtml = `
|
||
<div class="hero-card discovery-selected-hero mobile-secondary-card" style="padding:18px;">
|
||
<div class="entity-cell">
|
||
<div class="avatar-lg">${escapeHtml(initials(getAccountName(selected) || "SF"))}</div>
|
||
<div>
|
||
<h3>${escapeHtml(getAccountName(selected) || "还没有选中账号")}</h3>
|
||
<p>${escapeHtml(getAccountProfileUrl(selected) || selected?.signature || "先从上方列表选一个账号,这里会展示当前对象。")}</p>
|
||
</div>
|
||
</div>
|
||
<div class="mini-grid">
|
||
<div class="mini-card"><small>作品数</small><strong>${escapeHtml(formatNumber(selected?.video_summary?.count))}</strong></div>
|
||
<div class="mini-card"><small>高分作品</small><strong>${escapeHtml(formatNumber(topVideos.length))}</strong></div>
|
||
<div class="mini-card"><small>报告数</small><strong>${escapeHtml(formatNumber(reports.length))}</strong></div>
|
||
<div class="mini-card"><small>已绑对标</small><strong>${escapeHtml(formatNumber(linkedAccounts.length))}</strong></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
let detailBodyHtml = "";
|
||
if (activeTab === "overview") {
|
||
detailBodyHtml = renderDiscoveryOverviewSection({
|
||
selected,
|
||
selectedProject,
|
||
importedSources,
|
||
tracked,
|
||
topVideos,
|
||
reports,
|
||
latestVideos,
|
||
currentPlatformLabel,
|
||
topVideoBatchResult
|
||
});
|
||
} else if (activeTab === "snapshots") {
|
||
detailBodyHtml = renderDouyinInsightPanel();
|
||
} else {
|
||
detailBodyHtml = renderDiscoveryRelationsSection(linkedAccounts, similarCandidates);
|
||
}
|
||
const discoveryActionsHtml = isMobileUi
|
||
? `${button("导入主页", "open-import-homepage")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: discoveryHandoffAttrs })} ${button("存对标", "open-benchmark-link", "primary")}`
|
||
: `${button("导入主页", "open-import-homepage")} ${button("导入当前对标", "open-import-selected-account", "secondary")} ${button(tracked ? "已在跟踪" : "加入跟踪", "open-track-selected-account", "secondary")} ${button("账号分析", "analyze-selected-account", "secondary")} ${button("高分分析", "analyze-top-videos", "secondary")} ${button("查相似", "open-similar-search", "secondary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: discoveryHandoffAttrs })} ${button("存对标", "open-benchmark-link", "primary")}`;
|
||
return screenShell(
|
||
"找对标",
|
||
`这里已经接入真实${currentPlatformLabel}账号列表和单账号详情。`,
|
||
discoveryActionsHtml,
|
||
`
|
||
${renderMainAgentLandingNotice("discovery")}
|
||
<div class="panel">
|
||
<div class="toolbar">
|
||
<div class="toolbar-stack">
|
||
<label class="search search-inline">
|
||
<span>⌕</span>
|
||
<input data-action="discovery-query" value="${escapeHtml(appState.discoveryQuery)}" placeholder="搜账号名、账号标识、主页链接、关键词" />
|
||
</label>
|
||
<div class="filters mobile-priority-filters">
|
||
<div class="filter">平台:${escapeHtml(currentPlatformLabel)}</div>
|
||
<div class="filter">账号数:${escapeHtml(formatNumber(accounts.length))}</div>
|
||
<div class="filter">报告:${escapeHtml(formatNumber(reports.length))}</div>
|
||
<div class="filter">作品:${escapeHtml(formatNumber(effectiveVideos.length))}</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="chip-row">
|
||
${renderPlatformSwitchChips(currentPlatform)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only mobile-discovery-priority">
|
||
<div class="mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>下一步先做</strong>
|
||
<span class="tag blue">${escapeHtml(getAccountName(selected) || "未选中")}</span>
|
||
</div>
|
||
<p>${escapeHtml(selected ? `先围绕 ${getAccountName(selected)} 做导入、分析和相似扩展。` : "先从账号池里选一个对象,再继续导入和分析。")}</p>
|
||
<div class="task-meta">
|
||
${actionTag("导入当前对标", "open-import-selected-account")}
|
||
${actionTag("账号分析", "analyze-selected-account")}
|
||
${actionTag("查相似", "open-similar-search")}
|
||
</div>
|
||
</div>
|
||
<div class="compact-summary-row mobile-discovery-selected-summary" style="margin-bottom:14px;">
|
||
<span class="tag blue">当前对标 ${escapeHtml(getAccountName(selected) || "未选中")}</span>
|
||
<span class="tag">${escapeHtml(importedSources.length ? `已接入 ${importedSources.length}` : "未接入项目")}</span>
|
||
<span class="tag ${tracked ? "green" : "blue"}">${escapeHtml(tracked ? "已加入跟踪" : "未加入跟踪")}</span>
|
||
<span class="tag ${reports.length ? "green" : "blue"}">${escapeHtml(reports.length ? `报告 ${reports.length}` : "暂无报告")}</span>
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only mobile-account-list">
|
||
${accounts.map((account) => {
|
||
const active = account.id === appState.selectedAccountId;
|
||
return `
|
||
<button class="account-select-card ${active ? "is-active" : ""}" type="button" data-action="select-account" data-account-id="${escapeHtml(account.id)}">
|
||
<div class="entity-cell">
|
||
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
|
||
<div>
|
||
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
|
||
<div class="cell-desc">${escapeHtml(getAccountSubtitle(account) || "未填账号标识")}</div>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-inline">
|
||
<span>作品 ${escapeHtml(formatNumber(account.video_summary?.count))}</span>
|
||
<span>均播 ${escapeHtml(formatNumber(account.video_summary?.avg_play))}</span>
|
||
<span>均赞 ${escapeHtml(formatNumber(account.video_summary?.avg_like))}</span>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag ${active ? "green" : "blue"}">${active ? "当前选中" : "点击查看"}</span>
|
||
<span class="tag">${escapeHtml(account.sync_status || "ready")}</span>
|
||
</div>
|
||
</button>
|
||
`;
|
||
}).join("") || `<div class="empty-state">当前平台没有账号数据。</div>`}
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table class="account-table">
|
||
<thead>
|
||
<tr>
|
||
<th>账号</th>
|
||
<th>签名</th>
|
||
<th>作品数</th>
|
||
<th>平均播放</th>
|
||
<th>平均点赞</th>
|
||
<th>动作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${accounts.map((account) => `
|
||
<tr>
|
||
<td>
|
||
<div class="entity-cell">
|
||
<div class="avatar-lg">${escapeHtml(initials(getAccountName(account)))}</div>
|
||
<div>
|
||
<div class="cell-title">${escapeHtml(getAccountName(account))}</div>
|
||
<div class="cell-desc">${escapeHtml(getAccountSubtitle(account) || "-")}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>${escapeHtml(brief(account.signature || "暂无签名", 36))}</td>
|
||
<td><span class="metric">${escapeHtml(formatNumber(account.video_summary?.count))}</span></td>
|
||
<td>${escapeHtml(formatNumber(account.video_summary?.avg_play))}</td>
|
||
<td>${escapeHtml(formatNumber(account.video_summary?.avg_like))}</td>
|
||
<td><div class="row-meta"><span class="tag" data-action="select-account" data-account-id="${escapeHtml(account.id)}">查看</span></div></td>
|
||
</tr>
|
||
`).join("") || `<tr><td colspan="6">当前平台没有账号数据。</td></tr>`}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="panel pad page-section-panel" id="selected-account-anchor" style="border-top:1px solid var(--line); border-radius:0; box-shadow:none;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>当前选中对标</h3>
|
||
<div class="panel-subtitle">先看核心信息,再按分类切换深层内容。</div>
|
||
</div>
|
||
<span class="tag blue">${escapeHtml(getAccountName(selected) || "未选中")}</span>
|
||
</div>
|
||
${isMobileUi ? "" : `
|
||
<div class="mobile-only mobile-flow-focus-card mobile-secondary-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>下一步先做</strong>
|
||
<span class="tag blue">${escapeHtml(detailTabs.find((tab) => tab.value === activeTab)?.label || "账号概览")}</span>
|
||
</div>
|
||
<p>${escapeHtml(selected ? `先围绕 ${getAccountName(selected)} 做导入、分析和相似扩展。` : "先从账号池里选一个对象,再继续导入和分析。")}</p>
|
||
<div class="task-meta">
|
||
${actionTag("导入当前对标", "open-import-selected-account")}
|
||
${actionTag("账号分析", "analyze-selected-account")}
|
||
${actionTag("查相似", "open-similar-search")}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row mobile-secondary-card" style="margin-bottom:14px;">
|
||
<span class="tag blue">当前对标 ${escapeHtml(getAccountName(selected) || "未选中")}</span>
|
||
<span class="tag">${escapeHtml(importedSources.length ? `已接入 ${importedSources.length}` : "未接入项目")}</span>
|
||
<span class="tag ${tracked ? "green" : "blue"}">${escapeHtml(tracked ? "已加入跟踪" : "未加入跟踪")}</span>
|
||
<span class="tag ${reports.length ? "green" : "blue"}">${escapeHtml(reports.length ? `报告 ${reports.length}` : "暂无报告")}</span>
|
||
</div>
|
||
`}
|
||
${selectedSummaryHtml}
|
||
${renderDetailTabs("discoveryDetailTab", detailTabs)}
|
||
${detailBodyHtml}
|
||
</div>
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderTrackingScreen() {
|
||
if (!appState.dashboard) {
|
||
if (isAutoConnectionPending()) {
|
||
return renderAutoConnectingScreen("跟踪账号", "工作区就绪后,这里会自动加载真实跟踪对象和日报。");
|
||
}
|
||
return screenShell("跟踪账号", "完成工作区自动连接后才能生成真实日报。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("日报未加载", "当前还没有可用的对标账号数据。"));
|
||
}
|
||
const currentPlatform = getCurrentPlatformValue();
|
||
const trackedAccounts = getTrackingAccounts().filter((item) => item.platform === currentPlatform);
|
||
const digestItems = getTrackingDigestItems(12, { platform: currentPlatform });
|
||
const platformCursor = getTrackingCursorForPlatform(currentPlatform) || appState.lastSeenAt;
|
||
const cursorLabel = platformCursor ? formatDateTime(platformCursor) : "尚未记录";
|
||
const trackingNotice = getCurrentTrackingRefreshNotice(currentPlatform);
|
||
const trackingHandoffAttrs = buildMainAgentHandoffAttrs({
|
||
sourceScreen: "tracking",
|
||
sourceActionKey: "tracking-handoff",
|
||
intentKey: "tracking_digest",
|
||
title: `继续处理${getPlatformShortLabel(currentPlatform)}跟踪日报`,
|
||
goal: `基于当前${getPlatformShortLabel(currentPlatform)}跟踪账号和日报,生成下一步跟进计划`,
|
||
summary: "主 Agent 会结合已跟踪账号、日报窗口和更新摘要,给出下一步动作。",
|
||
platform: currentPlatform,
|
||
platformScope: "single_platform",
|
||
planSteps: [
|
||
"读取当前平台跟踪账号和最新日报",
|
||
"识别值得继续跟进的账号或内容",
|
||
"生成一版跟踪推进计划"
|
||
]
|
||
});
|
||
return screenShell(
|
||
"跟踪账号",
|
||
`这里已经接上真实${getPlatformShortLabel(currentPlatform)}跟踪对象和按上次打开后的更新日报。`,
|
||
`${button("同步全部", "refresh-tracking")} ${button("标记已读", "mark-tracking-read")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: trackingHandoffAttrs })} ${button("跳到找对标", "goto-discovery", "primary")}`,
|
||
`
|
||
${renderMainAgentLandingNotice("tracking")}
|
||
<div class="hero-card mobile-secondary-card">
|
||
<h3>日报逻辑</h3>
|
||
<p>按上次打开后汇总。上次打开距今 ${escapeHtml(daysSince(platformCursor))} 天,本次优先展示有更新且值得借鉴的内容。</p>
|
||
<div class="chip-row" style="margin-top:14px;">
|
||
${renderPlatformSwitchChips(currentPlatform)}
|
||
<span class="chip">上次已读 ${escapeHtml(cursorLabel)}</span>
|
||
</div>
|
||
</div>
|
||
${trackingNotice ? `
|
||
<div class="task-item" style="margin-top:16px;">
|
||
<h4>${escapeHtml(trackingNotice.title || "后台同步状态")}</h4>
|
||
<p>${escapeHtml(trackingNotice.summary || "最近一次跟踪同步已经进入后台执行。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${trackingNotice.tone === "orange" ? "orange" : trackingNotice.tone === "green" ? "green" : "blue"}">${escapeHtml(trackingNotice.mode === "single" ? "单账号" : "批量同步")}</span>
|
||
${trackingNotice.items?.[0]?.sync_job_id ? `<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(trackingNotice.items[0].sync_job_id)}">看任务详情</span>` : ""}
|
||
${actionTag("去生产中心", "goto-production")}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前跟踪任务</strong>
|
||
<span class="tag blue">${escapeHtml(getPlatformShortLabel(currentPlatform))}</span>
|
||
</div>
|
||
<p>${escapeHtml(
|
||
digestItems.length
|
||
? `先看最近 ${Math.min(digestItems.length, 12)} 条更新日报,再决定同步重点账号还是回到找对标。`
|
||
: trackedAccounts.length
|
||
? "先同步重点跟踪账号,等新作品出现后再回来看日报。"
|
||
: "先去找对标把值得持续观察的账号加入跟踪。"
|
||
)}</p>
|
||
<div class="task-meta">
|
||
${actionTag(digestItems.length ? "标记已读" : "同步全部", digestItems.length ? "mark-tracking-read" : "refresh-tracking")}
|
||
${actionTag("跳到找对标", "goto-discovery")}
|
||
${actionTag("交给主 Agent", "handoff-to-main-agent", trackingHandoffAttrs)}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-top:14px; margin-bottom:14px;">
|
||
<span class="tag blue">跟踪 ${escapeHtml(formatNumber(trackedAccounts.length))}</span>
|
||
<span class="tag green">日报 ${escapeHtml(formatNumber(digestItems.length))}</span>
|
||
<span class="tag">${escapeHtml(daysSince(platformCursor))} 天窗口</span>
|
||
<span class="tag">${escapeHtml(getPlatformShortLabel(currentPlatform))}</span>
|
||
</div>
|
||
<div class="layout-grid grid-main" style="margin-top:18px;">
|
||
<div class="side-stack">
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>跟踪列表</h3><div class="panel-subtitle">真实跟踪对象与绑定 Agent</div></div><span class="tag">${escapeHtml(formatNumber(trackedAccounts.length))} 个</span></div>
|
||
<div class="list">
|
||
${trackedAccounts.map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.account?.nickname || "未命名账号")}</h4>
|
||
<p>最近作品 ${escapeHtml(formatNumber(item.account?.video_summary?.count))} 条 · 平均播放 ${escapeHtml(formatNumber(item.account?.video_summary?.avg_play))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag green">已跟踪</span>
|
||
<span class="tag">${escapeHtml(item.assistant_name || "未绑 Agent")}</span>
|
||
${actionTag("立即同步", "refresh-tracked-account", `data-tracked-account-id="${escapeHtml(item.tracked_account_id)}"`)}
|
||
${actionTag("看详情", "select-account", `data-account-id="${escapeHtml(item.tracked_account_id)}"`)}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item"><h4>暂无跟踪账号</h4><p>先去找对标把重点账号加入跟踪。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>更新日报</h3><div class="panel-subtitle">优先看最近更新的作品摘要</div></div><span class="tag blue">${escapeHtml(formatNumber(digestItems.length))} 条</span></div>
|
||
<div class="list">
|
||
${digestItems.map((item) => `
|
||
<div class="review-card compact ${review.id === appState.reviewFocusId ? "active" : ""}" data-review-id="${escapeHtml(review.id)}">
|
||
<h4>${escapeHtml(item.account?.nickname || "账号")} · ${escapeHtml(item.video?.title || item.video?.description || "最新作品")}</h4>
|
||
<p>${escapeHtml(item.summary || `发布时间 ${formatDateTime(item.video?.published_at)},建议继续判断借鉴点。`)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(getPlatformShortLabel(item.platform || currentPlatform))}</span>
|
||
<span class="tag green">${escapeHtml(item.is_high_value ? "高价值" : "可学习")}</span>
|
||
${item.assistant_name ? `<span class="tag">${escapeHtml(item.assistant_name)}</span>` : ""}
|
||
${item.video?.share_url ? `<a class="tag" href="${escapeHtml(item.video.share_url)}" target="_blank" rel="noreferrer">打开作品</a>` : ""}
|
||
</div>
|
||
${safeArray(item.borrowing_points).length ? `
|
||
<div class="task-meta">
|
||
${safeArray(item.borrowing_points).slice(0, 3).map((point) => `<span class="tag blue">${escapeHtml(point)}</span>`).join("")}
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
`).join("") || `<div class="review-card"><h4>暂无日报</h4><p>先把账号加入跟踪,并等待新作品更新。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderAutomationScreen() {
|
||
const jobs = safeArray(appState.dashboard?.recent_jobs);
|
||
const analysisJobs = jobs.filter((item) => item.line_type === "analysis").length;
|
||
const aiVideoJobs = jobs.filter((item) => item.line_type === "ai_video").length;
|
||
const realCutJobs = jobs.filter((item) => item.line_type === "real_cut").length;
|
||
const overview = getIntegrationOverview();
|
||
const tabs = [
|
||
{ value: "health", label: "依赖健康" },
|
||
{ value: "guards", label: "动作防呆" }
|
||
];
|
||
const activeTab = getActiveDetailTab("automationDetailTab", tabs);
|
||
const automationHandoffAttrs = buildMainAgentHandoffAttrs({
|
||
sourceScreen: "automation",
|
||
sourceActionKey: "automation-main-agent-handoff",
|
||
intentKey: "ops_admin",
|
||
title: "继续检查自动流程",
|
||
goal: "继续检查自动流程",
|
||
summary: "让主 Agent 结合依赖健康和动作防呆状态,给出下一步处理建议。",
|
||
platform: getPreferredPlatform(),
|
||
planSteps: ["读取当前依赖健康", "检查动作防呆和拦截状态", "生成下一步处理建议"]
|
||
});
|
||
return screenShell(
|
||
"自动流程",
|
||
"自动同步、日报生成和失败补跑先统一看这里。",
|
||
`${button("刷新", "refresh-data")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: automationHandoffAttrs })} ${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("去生产", "goto-production", "primary")}`,
|
||
`
|
||
${renderMainAgentLandingNotice("automation")}
|
||
<div class="hero-card mobile-secondary-card">
|
||
<h3>自动流程</h3>
|
||
<p>当前按真实任务量和依赖健康状态给出看板,自动流程受阻时会直接在这里拦住动作。</p>
|
||
<div class="mini-grid">
|
||
<div class="mini-card"><small>分析任务</small><strong>${escapeHtml(formatNumber(analysisJobs))}</strong></div>
|
||
<div class="mini-card"><small>AI 视频</small><strong>${escapeHtml(formatNumber(aiVideoJobs))}</strong></div>
|
||
<div class="mini-card"><small>实拍剪辑</small><strong>${escapeHtml(formatNumber(realCutJobs))}</strong></div>
|
||
<div class="mini-card"><small>内容源</small><strong>${escapeHtml(formatNumber(appState.contentSources.length))}</strong></div>
|
||
</div>
|
||
</div>
|
||
<div class="panel pad" style="margin-top:18px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>自动链路工作区</h3>
|
||
<div class="panel-subtitle">普通用户只看健康状态和动作是否可执行,系统治理移到管理员配置台。</div>
|
||
</div>
|
||
<span class="tag ${escapeHtml(overview.tone)}">${escapeHtml(overview.headline)}</span>
|
||
</div>
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前自动流程任务</strong>
|
||
<span class="tag blue">${escapeHtml(tabs.find((tab) => tab.value === activeTab)?.label || "依赖健康")}</span>
|
||
</div>
|
||
<p>${escapeHtml(
|
||
activeTab === "guards"
|
||
? "先确认 AI 视频、实拍剪辑这类动作是否被拦截,再决定回到生产还是交给主 Agent。"
|
||
: "先看当前依赖健康,再决定哪些动作可以继续自动推进。"
|
||
)}</p>
|
||
<div class="task-meta">
|
||
${activeTab === "guards"
|
||
? `${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${actionTag("交给主 Agent", "handoff-to-main-agent", automationHandoffAttrs)}`
|
||
: `${actionTag("刷新", "refresh-data")} ${actionTag("动作防呆", "select-page-tab", `data-page-tab-key="automationDetailTab" data-page-tab-value="guards"`)} ${actionTag("交给主 Agent", "handoff-to-main-agent", automationHandoffAttrs)}`
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-bottom:14px;">
|
||
<span class="tag blue">分析 ${escapeHtml(formatNumber(analysisJobs))}</span>
|
||
<span class="tag green">AI 视频 ${escapeHtml(formatNumber(aiVideoJobs))}</span>
|
||
<span class="tag">${escapeHtml(formatNumber(realCutJobs))} 实拍</span>
|
||
<span class="tag ${escapeHtml(overview.tone)}">${escapeHtml(overview.headline)}</span>
|
||
</div>
|
||
${renderDetailTabs("automationDetailTab", tabs)}
|
||
${activeTab === "health" ? renderIntegrationOverviewPanel({ showActions: false }) : `
|
||
<div class="panel pad automation-guard-panel" style="box-shadow:none;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>动作防呆</h3>
|
||
<div class="panel-subtitle">依赖不可用时,相关动作会在这里和生产页一起被拦住。</div>
|
||
</div>
|
||
<span class="tag ${escapeHtml(overview.tone)}">${escapeHtml(overview.headline)}</span>
|
||
</div>
|
||
<div class="task-meta integration-highlights">
|
||
<span class="tag ${getPipelineGuard("aiVideo").enabled ? "green" : "red"}">AI 视频 ${escapeHtml(getPipelineGuard("aiVideo").enabled ? "可执行" : "已拦截")}</span>
|
||
<span class="tag ${getPipelineGuard("realCut").enabled ? "green" : "red"}">实拍剪辑 ${escapeHtml(getPipelineGuard("realCut").enabled ? "可执行" : "已拦截")}</span>
|
||
<span class="tag blue">ASR ${escapeHtml(getIntegrationStatus(getIntegrationDetail("asr")).summary)}</span>
|
||
</div>
|
||
<div class="integration-actions" style="margin-top:14px;">
|
||
${renderPipelineButton("aiVideo", "primary")}
|
||
${renderPipelineButton("realCut")}
|
||
</div>
|
||
<div class="integration-note" style="margin-top:12px;">${escapeHtml(overview.subtitle)}</div>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderOwnedScreen() {
|
||
if (!appState.dashboard) {
|
||
if (isAutoConnectionPending()) {
|
||
return renderAutoConnectingScreen("我的账号", "工作区就绪后,这里会自动显示当前账号和建议动作。");
|
||
}
|
||
return screenShell("我的账号", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("我的账号未加载", "自动连接成功后,这里会展示当前账号和建议动作。"));
|
||
}
|
||
const me = appState.me || appState.session?.account || {};
|
||
const firstAssistant = safeArray(appState.dashboard.assistants)[0];
|
||
const selectedProject = getSelectedProject();
|
||
const jobs = safeArray(appState.dashboard.recent_jobs);
|
||
const completedJobs = jobs.filter((item) => item.status === "completed").length;
|
||
const activeJobs = jobs.filter((item) => item.status !== "completed").length;
|
||
return screenShell(
|
||
"我的账号",
|
||
"这里先用当前登录账号和最近产出组合成第一版总览。",
|
||
`${button("刷新", "refresh-data")} ${button("去 Agent", "goto-playbook", "primary")}`,
|
||
`
|
||
<div class="hero-card mobile-secondary-card">
|
||
<div class="entity-cell">
|
||
<div class="avatar-lg">${escapeHtml(initials(me.display_name || me.username))}</div>
|
||
<div>
|
||
<h3>${escapeHtml(me.display_name || me.username || "当前账号")}</h3>
|
||
<p>${escapeHtml(firstAssistant?.generation_goal || "先创建 Agent,再把平台目标和变现方式补齐。")}</p>
|
||
</div>
|
||
</div>
|
||
<div class="mini-grid">
|
||
<div class="mini-card"><small>项目</small><strong>${escapeHtml(formatNumber(appState.dashboard.projects?.length))}</strong></div>
|
||
<div class="mini-card"><small>Agent</small><strong>${escapeHtml(formatNumber(appState.dashboard.assistants?.length))}</strong></div>
|
||
<div class="mini-card"><small>任务</small><strong>${escapeHtml(formatNumber(appState.dashboard.recent_jobs?.length))}</strong></div>
|
||
<div class="mini-card"><small>素材</small><strong>${escapeHtml(formatNumber(appState.documents.length))}</strong></div>
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前账号任务</strong>
|
||
<span class="tag blue">${escapeHtml(me.display_name || me.username || "当前账号")}</span>
|
||
</div>
|
||
<p>${escapeHtml(
|
||
activeJobs
|
||
? "先处理当前待推进任务,再决定是否继续补 Agent 或整理项目信息。"
|
||
: "当前任务压力不高,更适合补 Agent 策略、整理项目说明或继续跟踪。"
|
||
)}</p>
|
||
<div class="task-meta">
|
||
${actionTag(activeJobs ? "去生产中心" : "去 Agent", activeJobs ? "goto-production" : "goto-playbook")}
|
||
${actionTag("去我的项目", "goto-intake")}
|
||
${actionTag("看跟踪账号", "goto-tracking")}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-top:14px; margin-bottom:14px;">
|
||
<span class="tag blue">当前项目 ${escapeHtml(selectedProject?.name || "未选项目")}</span>
|
||
<span class="tag">${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")}</span>
|
||
<span class="tag ${activeJobs ? "orange" : "green"}">待推进 ${escapeHtml(formatNumber(activeJobs))}</span>
|
||
<span class="tag green">已完成 ${escapeHtml(formatNumber(completedJobs))}</span>
|
||
</div>
|
||
<div class="layout-grid grid-main" style="margin-top:18px;">
|
||
<div class="side-stack">
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>当前负责范围</h3><div class="panel-subtitle">把你当前最常用的工作上下文放在一起。</div></div></div>
|
||
<div class="list">
|
||
<div class="task-item">
|
||
<h4>当前项目 · ${escapeHtml(selectedProject?.name || "未选项目")}</h4>
|
||
<p>${escapeHtml(selectedProject?.description || "当前还没有清晰项目说明,可以去“我的项目”补齐。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">项目 ${escapeHtml(formatNumber(appState.dashboard.projects?.length))}</span>
|
||
<span class="tag">${escapeHtml(getSelectedAssistant()?.name || "未选 Agent")}</span>
|
||
${actionTag("去我的项目", "goto-intake")}
|
||
</div>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>当前主 Agent</h4>
|
||
<p>${escapeHtml(firstAssistant?.generation_goal || firstAssistant?.description || "先在 Agent 页面补齐你的默认协作方式。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag green">${escapeHtml(firstAssistant?.name || "默认文案助手")}</span>
|
||
${actionTag("去 Agent", "goto-playbook")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>最近工作摘要</h3><div class="panel-subtitle">用最少的信息告诉你最近跑了什么。</div></div></div>
|
||
<div class="mini-grid" style="margin-top:0;">
|
||
<div class="mini-card"><small>待推进任务</small><strong>${escapeHtml(formatNumber(activeJobs))}</strong></div>
|
||
<div class="mini-card"><small>已完成任务</small><strong>${escapeHtml(formatNumber(completedJobs))}</strong></div>
|
||
<div class="mini-card"><small>平台数</small><strong>${escapeHtml(formatNumber(getPlatformOptions().length))}</strong></div>
|
||
<div class="mini-card"><small>对标数</small><strong>${escapeHtml(formatNumber(appState.accounts.length))}</strong></div>
|
||
</div>
|
||
<div class="task-item" style="margin-top:14px;">
|
||
<h4>推荐下一步</h4>
|
||
<p>${escapeHtml(activeJobs ? "先去生产中心处理待推进任务,再回到首页看下一条动作。" : "当前任务不重,适合补 Agent 策略或整理项目说明。")}</p>
|
||
<div class="task-meta">
|
||
${actionTag(activeJobs ? "去生产中心" : "去 Agent", activeJobs ? "goto-production" : "goto-playbook")}
|
||
${actionTag("看跟踪账号", "goto-tracking")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderPlaybookScreen() {
|
||
if (!appState.dashboard) {
|
||
if (isAutoConnectionPending()) {
|
||
return renderAutoConnectingScreen("Agent", "工作区就绪后,这里会自动加载真实 Agent 列表和模型。");
|
||
}
|
||
return screenShell("Agent", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("Agent 未加载", "自动连接成功后,这里会展示真实 Agent 列表和模型。"));
|
||
}
|
||
const assistants = safeArray(appState.dashboard.assistants);
|
||
const models = safeArray(appState.dashboard.model_profiles);
|
||
const currentModel = getCurrentModelProfile();
|
||
const currentAssistant = getSelectedAssistant();
|
||
const localCatalog = appState.localModelCatalog || {};
|
||
const activeAdminOverrideNotice = appState.onelinerGovernanceEffective?.active_admin_override_notice || null;
|
||
const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean);
|
||
const playbookHandoffAttrs = buildMainAgentHandoffAttrs({
|
||
sourceScreen: "playbook",
|
||
sourceActionKey: "playbook-main-agent-handoff",
|
||
intentKey: "custom",
|
||
title: "继续梳理当前 Agent 工作区",
|
||
goal: "继续梳理当前 Agent 工作区",
|
||
summary: "让主 Agent 结合当前 Agent、模型和策略状态,给出下一步执行建议。",
|
||
platform: appState.onelinerGovernanceEffective?.platform || appState.onelinerProfile?.default_platform || getPreferredPlatform(),
|
||
planSteps: ["读取当前 Agent 与模型配置", "检查当前策略与平台 Agent 缺口", "生成下一步执行建议"]
|
||
});
|
||
const tabs = [
|
||
{ value: "workspace", label: "当前 Agent 工作台" },
|
||
{ value: "platform_agents", label: "平台 Agent" },
|
||
{ value: "models", label: "模型与学习" }
|
||
];
|
||
const activeTab = getActiveDetailTab("playbookDetailTab", tabs);
|
||
return screenShell(
|
||
"Agent",
|
||
"这里接真实 Agent 列表,当前已经支持切换和编辑 Agent。",
|
||
`${button("配置 OneLiner", "open-oneliner-profile")} ${button("看配置历史", "open-oneliner-profile-history", "secondary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: playbookHandoffAttrs })} ${button("设主模型", "open-preferred-model")} ${button("新建 Agent", "open-create-assistant")} ${button("去生产", "goto-production", "primary")}`,
|
||
`
|
||
${renderMainAgentLandingNotice("playbook")}
|
||
<div class="hero-card mobile-secondary-card">
|
||
<h3>Agent 概览</h3>
|
||
<p>先定项目、平台和主模型,再导入内容让 Agent 学习。</p>
|
||
<div class="chip-row" style="margin-top:14px;">
|
||
${models.slice(0, 6).map((model) => `<span class="chip ${model.is_default ? "active" : ""}">${escapeHtml(model.name)}</span>`).join("") || `<span class="chip active">暂无模型</span>`}
|
||
</div>
|
||
</div>
|
||
<div class="panel pad" style="margin-top:18px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>Agent 工作区</h3>
|
||
<div class="panel-subtitle">先处理你当前真的会用到的 Agent 信息,系统治理内容已移到管理员配置台。</div>
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前 Agent 任务</strong>
|
||
<span class="tag blue">${escapeHtml(tabs.find((tab) => tab.value === activeTab)?.label || "当前 Agent 工作台")}</span>
|
||
</div>
|
||
<p>${escapeHtml(
|
||
activeTab === "platform_agents"
|
||
? "先确认当前平台 Agent 是否已接上默认策略,再决定是否继续下放个性化策略。"
|
||
: activeTab === "models"
|
||
? "先确定主模型和本机网关状态,再回到 Agent 工作区继续执行。"
|
||
: activeAdminOverrideNotice?.title
|
||
? "先看当前管理员覆盖,再决定是调整我的策略还是继续交给主 Agent。"
|
||
: currentAssistant
|
||
? `先围绕 ${currentAssistant.name} 调整配置,再决定是否继续交给主 Agent。`
|
||
: "先配置 OneLiner 或创建第一个 Agent,再把当前项目交给它。"
|
||
)}</p>
|
||
<div class="task-meta">
|
||
${activeTab === "platform_agents"
|
||
? `${actionTag("看平台 Agent", "select-page-tab", `data-page-tab-key="playbookDetailTab" data-page-tab-value="platform_agents"`)} ${actionTag("交给主 Agent", "handoff-to-main-agent", playbookHandoffAttrs)}`
|
||
: activeTab === "models"
|
||
? `${actionTag("设主模型", "open-preferred-model")} ${actionTag("回工作区", "select-page-tab", `data-page-tab-key="playbookDetailTab" data-page-tab-value="workspace"`)}`
|
||
: `${actionTag("配置 OneLiner", "open-oneliner-profile")} ${actionTag("看配置历史", "open-oneliner-profile-history")} ${actionTag(currentAssistant ? "去生产" : "新建 Agent", currentAssistant ? "goto-production" : "open-create-assistant")} ${actionTag("交给主 Agent", "handoff-to-main-agent", playbookHandoffAttrs)}`
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-bottom:14px;">
|
||
<span class="tag blue">主 Agent ${escapeHtml(appState.onelinerProfile?.display_name || "OneLiner")}</span>
|
||
<span class="tag">${escapeHtml(currentAssistant ? `当前 ${currentAssistant.name}` : "当前未选 Agent")}</span>
|
||
<span class="tag green">模型 ${escapeHtml(formatNumber(models.length))}</span>
|
||
<span class="tag ${activeAdminOverrideNotice?.title ? "orange" : "blue"}">${escapeHtml(activeAdminOverrideNotice?.title ? "有管理员覆盖" : "无管理员覆盖")}</span>
|
||
</div>
|
||
${renderDetailTabs("playbookDetailTab", tabs)}
|
||
${activeTab === "workspace" ? `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>OneLiner 主 Agent</h3>
|
||
<div class="panel-subtitle">前端还没上的功能由它兜底承接,并调度平台 Agent。</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(appState.onelinerProfile?.display_name || "OneLiner")}</span>
|
||
<span class="tag">${escapeHtml(appState.onelinerProfile?.default_platform ? platformLabel(appState.onelinerProfile.default_platform) : "未设默认平台")}</span>
|
||
${actionTag("交给主 Agent", "handoff-to-main-agent", playbookHandoffAttrs)}
|
||
</div>
|
||
</div>
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(appState.onelinerProfile?.long_term_goal || "还没有设置长期目标")}</h4>
|
||
<p>${escapeHtml(appState.onelinerProfile?.notes || "你可以把用户长期目标、账号目标、默认平台都绑给 OneLiner,再让它去调度平台 Agent。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">会话 ${escapeHtml(formatNumber(safeArray(appState.onelinerSessions).length))}</span>
|
||
<span class="tag">平台 Agent ${escapeHtml(formatNumber(safeArray(appState.platformAgents).length))}</span>
|
||
<span class="tag clickable-tag" data-action="open-oneliner-profile">编辑配置</span>
|
||
<span class="tag clickable-tag" data-action="open-oneliner-profile-history">历史与回滚</span>
|
||
</div>
|
||
</div>
|
||
${activeAdminOverrideNotice?.title ? `
|
||
<div class="task-item compact" style="margin-top:12px; border-color:rgba(245, 158, 11, 0.28); background:linear-gradient(180deg, rgba(255, 250, 240, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);">
|
||
<h4>管理员覆盖生效中</h4>
|
||
<p>${escapeHtml(activeAdminOverrideNotice.summary || "当前 OneLiner 和平台 Agent 都会先遵循管理员覆盖层。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag orange">${escapeHtml(activeAdminOverrideNotice.title || "管理员覆盖")}</span>
|
||
<span class="tag clickable-tag" data-action="open-user-global-policy">看我的策略</span>
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${renderGovernanceSummaryCard({
|
||
title: "我的策略与历史",
|
||
subtitle: appState.userGlobalPolicy?.current_version?.summary || "你和主 Agent 的策略对话,会先沉淀成用户全局策略,再按需要下放到单平台。",
|
||
effective: appState.onelinerGovernanceEffective,
|
||
actions: [
|
||
{
|
||
action: "open-user-global-policy",
|
||
label: `编辑全局策略 · 历史 ${formatNumber(appState.userGlobalPolicy?.versions?.count || 0)}`
|
||
},
|
||
{
|
||
action: "open-user-global-policy-history",
|
||
label: "查看全局历史"
|
||
},
|
||
{
|
||
action: "open-user-platform-policy",
|
||
label: `编辑当前平台策略 · 历史 ${formatNumber(appState.userCurrentPlatformPolicy?.versions?.count || 0)}`,
|
||
platform: appState.onelinerGovernanceEffective?.platform || appState.onelinerProfile?.default_platform || getPreferredPlatform()
|
||
},
|
||
{
|
||
action: "open-user-platform-policy-history",
|
||
label: "查看当前平台历史",
|
||
platform: appState.onelinerGovernanceEffective?.platform || appState.onelinerProfile?.default_platform || getPreferredPlatform()
|
||
}
|
||
]
|
||
})}
|
||
</div>
|
||
<div class="panel pad" style="box-shadow:none; margin-top:18px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>当前 Agent</h3>
|
||
<div class="panel-subtitle">后续文案生成、对标绑定和复盘默认都会优先使用这里选中的 Agent。</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
${currentAssistant ? `<span class="tag blue">已选</span>` : `<span class="tag red">未选</span>`}
|
||
${currentAssistant ? `<span class="tag clickable-tag" data-action="open-edit-assistant" data-assistant-id="${escapeHtml(currentAssistant.id)}">编辑</span>` : ""}
|
||
</div>
|
||
</div>
|
||
${currentAssistant ? `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(currentAssistant.name)}</h4>
|
||
<p>${escapeHtml(currentAssistant.generation_goal || currentAssistant.description || "先补齐这个 Agent 的目标和说明。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(models.find((item) => item.id === currentAssistant.model_profile_id)?.name || "默认模型")}</span>
|
||
<span class="tag blue">${escapeHtml(formatNumber(safeArray(currentAssistant.knowledge_base_ids).length))} 条知识库</span>
|
||
<span class="tag">${escapeHtml(brief(currentAssistant.description || "暂无说明", 22))}</span>
|
||
</div>
|
||
</div>
|
||
` : `<div class="task-item"><h4>还没有可用 Agent</h4><p>先创建一个 Agent,再把当前项目的内容都交给它学习。</p></div>`}
|
||
</div>
|
||
<div class="panel pad" style="box-shadow:none; margin-top:18px;">
|
||
<div class="panel-head"><div><h3>Agent 列表</h3><div class="panel-subtitle">当前接的是后端 assistants</div></div></div>
|
||
<div class="list">
|
||
${assistants.map((assistant) => `
|
||
<div class="task-item ${assistant.id === currentAssistant?.id ? "active" : ""}">
|
||
<h4>${escapeHtml(assistant.name)}</h4>
|
||
<p>${escapeHtml(assistant.description || assistant.generation_goal || "暂无说明")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">知识库 ${escapeHtml(formatNumber(safeArray(assistant.knowledge_base_ids).length))}</span>
|
||
<span class="tag">${escapeHtml(models.find((item) => item.id === assistant.model_profile_id)?.name || "默认模型")}</span>
|
||
<span class="tag clickable-tag" data-action="select-assistant" data-assistant-id="${escapeHtml(assistant.id)}">${assistant.id === currentAssistant?.id ? "当前 Agent" : "设为当前"}</span>
|
||
<span class="tag clickable-tag" data-action="open-edit-assistant" data-assistant-id="${escapeHtml(assistant.id)}">编辑</span>
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item"><h4>还没有 Agent</h4><p>下一步可以直接把创建动作接进来。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>最近生成</h3><div class="panel-subtitle">当前先承接文案生成结果</div></div></div>
|
||
<div id="recent-generated-copy-anchor"></div>
|
||
${appState.lastGeneratedCopy ? `
|
||
<div class="task-item">
|
||
<h4>${escapeHtml(appState.lastGeneratedCopy.assistantName)}</h4>
|
||
<p>${escapeHtml(appState.lastGeneratedCopy.content)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">需求:${escapeHtml(brief(appState.lastGeneratedCopy.prompt, 24))}</span>
|
||
<span class="tag">${escapeHtml(formatNumber(appState.lastGeneratedCopy.usedDocuments.length))} 条参考</span>
|
||
</div>
|
||
</div>
|
||
` : `<div class="task-item"><h4>还没有生成结果</h4><p>先点“生成文案”,这里会保留最近一次结果。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : activeTab === "platform_agents" ? `
|
||
<div style="margin-top:6px;">${renderPlatformAgentPanel()}</div>
|
||
` : `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>本机模型网关</h3>
|
||
<div class="panel-subtitle">当前默认分析会优先走本机 cli-proxy-api。</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag ${escapeHtml(localCatalog.reachable ? "green" : "red")}">${escapeHtml(localCatalog.reachable ? "在线" : "离线")}</span>
|
||
${localCatalog.management_url ? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(currentModel?.name || localCatalog.default_model || "GLM-5")}</h4>
|
||
<p>${escapeHtml(currentModel ? `${currentModel.model_name || "-"} · ${currentModel.base_url || "-"}` : (localCatalog.public_base_url || localCatalog.base_url || "尚未读取到网关地址"))}</p>
|
||
<div class="task-meta">
|
||
${gatewayModels.slice(0, 6).map((model) => `<span class="tag">${escapeHtml(model)}</span>`).join("") || `<span class="tag red">暂无可见模型</span>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel pad" style="box-shadow:none; margin-top:18px;">
|
||
<div class="panel-head"><div><h3>模型列表</h3><div class="panel-subtitle">来自真实 model_profiles</div></div></div>
|
||
<div class="playbook-list">
|
||
${models.map((model) => `
|
||
<div class="playbook-item ${model.is_default ? "active" : ""}">
|
||
<h4>${escapeHtml(model.name)}</h4>
|
||
<p>${escapeHtml(model.model_name || "-")} · ${escapeHtml(model.base_url || "-")}</p>
|
||
</div>
|
||
`).join("") || `<div class="playbook-item active"><h4>暂无模型</h4><p>先在“我的”里配置模型。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>最近学习素材</h3><div class="panel-subtitle">从知识库文档取最近内容</div></div></div>
|
||
<div class="list">
|
||
${appState.documents.slice(0, 4).map((doc) => `
|
||
<div class="task-item">
|
||
<h4>${escapeHtml(doc.title)}</h4>
|
||
<p>${escapeHtml(brief(doc.style_summary || doc.transcript_text || doc.combined_text, 72))}</p>
|
||
</div>
|
||
`).join("") || `<div class="task-item"><h4>还没有学习素材</h4><p>先去找对标导入一条主页或作品。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderProductionMobileTaskDeck({ activeTab, activeJobs, failedJobs, recoverableCount, works, recentDocs }) {
|
||
const taskCards = [];
|
||
if (activeTab === "recovery") {
|
||
const nextRecoverable = failedJobs.find((item) => item.recovery.recoverable) || null;
|
||
if (nextRecoverable) {
|
||
taskCards.push(`
|
||
<div class="task-item compact">
|
||
<h4>当前要先处理</h4>
|
||
<p>${escapeHtml(`${nextRecoverable.job.title || nextRecoverable.job.id} 仍可恢复,建议优先重开。`)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${nextRecoverable.recovery.recoverable ? "green" : "orange"}">${escapeHtml(nextRecoverable.recovery.label)}</span>
|
||
${actionTag(nextRecoverable.recovery.actionLabel || "立即恢复", "recover-job", `data-job-id="${escapeHtml(nextRecoverable.job.id)}"`)}
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
taskCards.push(`
|
||
<div class="task-item compact">
|
||
<h4>恢复面板</h4>
|
||
<p>${escapeHtml(recoverableCount ? `当前有 ${recoverableCount} 条任务可以直接恢复。` : "当前没有可直接恢复的失败任务。")}</p>
|
||
<div class="task-meta">
|
||
${actionTag("批量恢复", "batch-recover-jobs")}
|
||
${actionTag("恢复记录", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="recovery"`)}
|
||
</div>
|
||
</div>
|
||
`);
|
||
} else if (activeTab === "recorder") {
|
||
const status = appState.liveRecorderStatus || {};
|
||
const activeCount = safeArray(status.active_recordings).length;
|
||
taskCards.push(`
|
||
<div class="task-item compact">
|
||
<h4>当前要先处理</h4>
|
||
<p>${escapeHtml(status.running ? `Live Recorder 正在运行,当前有 ${activeCount} 路活动录制。` : "先确认 Live Recorder 是否在线,再检查录制源和文件。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${status.running ? "green" : "orange"}">${escapeHtml(status.running ? "运行中" : "待检查")}</span>
|
||
${actionTag("录制维护", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="recorder"`)}
|
||
</div>
|
||
</div>
|
||
`);
|
||
taskCards.push(`
|
||
<div class="task-item compact">
|
||
<h4>录制源与文件</h4>
|
||
<p>${escapeHtml(`录制源 ${formatNumber(safeArray(appState.liveRecorderSources).length)} 个 · 文件 ${formatNumber(safeArray(appState.liveRecorderFiles).length)} 个`)}</p>
|
||
<div class="task-meta">
|
||
${actionTag("交给主 Agent", "handoff-to-main-agent", buildMainAgentHandoffAttrs({
|
||
sourceScreen: "production",
|
||
sourceActionKey: "production-mobile-recorder-handoff",
|
||
intentKey: "production_coordination",
|
||
title: "继续处理录制维护",
|
||
goal: "继续处理录制维护",
|
||
summary: "结合录制维护状态给出下一步动作。",
|
||
platform: getPreferredPlatform(),
|
||
platformScope: "single_platform",
|
||
planSteps: ["读取录制维护状态", "识别当前阻塞项", "生成下一步处理动作"]
|
||
}))}
|
||
</div>
|
||
</div>
|
||
`);
|
||
} else if (activeTab === "outputs") {
|
||
const topWork = works[0] || null;
|
||
taskCards.push(`
|
||
<div class="task-item compact">
|
||
<h4>当前要先处理</h4>
|
||
<p>${escapeHtml(topWork ? `先看 ${describeVideo(topWork)} 的结果,再决定是否回到复盘。` : "先看最近产物和学习素材,再决定是否继续复盘或返回生产。")}</p>
|
||
<div class="task-meta">
|
||
${actionTag("去复盘", "goto-review")}
|
||
${actionTag("查看产物", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="outputs"`)}
|
||
</div>
|
||
</div>
|
||
`);
|
||
if (recentDocs.length) {
|
||
taskCards.push(`
|
||
<div class="task-item compact">
|
||
<h4>最近学习素材</h4>
|
||
<p>${escapeHtml(brief(recentDocs[0].title || recentDocs[0].style_summary || "最近文档", 72))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">学习素材</span>
|
||
<span class="tag">${escapeHtml(recentDocs[0].source_type || "document")}</span>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
} else {
|
||
const topJob = (activeJobs.length ? activeJobs : []).slice(0, 1)[0] || null;
|
||
taskCards.push(`
|
||
<div class="task-item compact">
|
||
<h4>当前要先处理</h4>
|
||
<p>${escapeHtml(topJob ? `${topJob.title} 还在推进中,建议先看状态再决定是否做 AI 视频或实拍剪辑。` : "先看当前生产队列,再决定是否继续恢复或进入复盘。")}</p>
|
||
<div class="task-meta">
|
||
${actionTag("交给主 Agent", "handoff-to-main-agent", buildMainAgentHandoffAttrs({
|
||
sourceScreen: "production",
|
||
sourceActionKey: "production-mobile-queue-handoff",
|
||
intentKey: "production_coordination",
|
||
title: "继续推进生产队列",
|
||
goal: "继续推进生产队列",
|
||
summary: "结合当前生产队列给出下一步动作。",
|
||
platform: getPreferredPlatform(),
|
||
platformScope: "single_platform",
|
||
planSteps: ["读取当前生产队列", "识别最该优先推进的项", "生成下一步处理动作"]
|
||
}))}
|
||
${recoverableCount ? actionTag("看失败恢复", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="recovery"`) : actionTag("去复盘", "goto-review")}
|
||
</div>
|
||
</div>
|
||
`);
|
||
if (topJob) {
|
||
taskCards.push(`
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(topJob.title)}</h4>
|
||
<p>${escapeHtml(brief(topJob.style_summary || topJob.transcript_text || topJob.error || "暂无摘要", 84))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${statusTone(topJob.status)}">${escapeHtml(topJob.status)}</span>
|
||
<span class="tag">${escapeHtml(topJob.line_type || "analysis")}</span>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
}
|
||
return `
|
||
<div class="mobile-only production-mobile-task-deck">
|
||
${taskCards.slice(0, 2).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderProductionScreen() {
|
||
if (!appState.dashboard) {
|
||
if (isAutoConnectionPending()) {
|
||
return renderAutoConnectingScreen("生产中心", "工作区就绪后,这里会自动加载真实任务和作品。");
|
||
}
|
||
return screenShell("生产中心", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("生产中心未加载", "自动连接成功后,这里会展示真实任务和作品。"));
|
||
}
|
||
const jobs = safeArray(appState.dashboard.recent_jobs);
|
||
const activeJobs = jobs.filter((item) => item.status !== "completed").slice(0, 4);
|
||
const failedJobs = getRecoverableFailedJobs();
|
||
const recoverableCount = failedJobs.filter((item) => item.recovery.recoverable).length;
|
||
const recentDocs = appState.documents.slice(0, 3);
|
||
const works = getProductionWorks(6);
|
||
const isMobileUi = isMobileViewport();
|
||
const productionHandoffAttrs = buildMainAgentHandoffAttrs({
|
||
sourceScreen: "production",
|
||
sourceActionKey: "production-handoff",
|
||
intentKey: "production_coordination",
|
||
title: "继续推进生产中心",
|
||
goal: "基于当前生产队列、失败任务和产物,生成下一步生产推进计划",
|
||
summary: "主 Agent 会结合生产队列、失败恢复和产物状态,给出下一步动作。",
|
||
platform: getPreferredPlatform(),
|
||
platformScope: "single_platform",
|
||
planSteps: [
|
||
"读取当前生产队列和失败任务",
|
||
"识别最该优先推进或恢复的项",
|
||
"生成一版生产推进计划"
|
||
]
|
||
});
|
||
const tabs = [
|
||
{ value: "queue", label: "生产队列" },
|
||
{ value: "recovery", label: "失败恢复" },
|
||
{ value: "recorder", label: "录制维护" },
|
||
{ value: "outputs", label: "作品与产物" }
|
||
];
|
||
const activeTab = getActiveDetailTab("productionDetailTab", tabs);
|
||
const productionActionsHtml = isMobileUi
|
||
? `${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: productionHandoffAttrs })}`
|
||
: `${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: productionHandoffAttrs })} ${button("去复盘", "goto-review", "primary")} ${button("批量恢复", "batch-recover-jobs", "secondary", { disabledReason: recoverableCount ? "" : "当前没有可恢复的失败任务" })}`;
|
||
return screenShell(
|
||
"生产中心",
|
||
"这里已经接上真实任务和知识库文档,后续再继续补任务创建动作。",
|
||
productionActionsHtml,
|
||
`
|
||
${renderMainAgentLandingNotice("production")}
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>生产队列</h3><div class="panel-subtitle">最近任务的真实状态</div></div></div>
|
||
<div class="layout-grid grid-4 production-queue-grid" style="margin-top:16px;">
|
||
<div class="queue-card"><h4>分析任务</h4><p>最近 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "analysis").length))} 条</p></div>
|
||
<div class="queue-card"><h4>实拍剪辑</h4><p>最近 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "real_cut").length))} 条</p></div>
|
||
<div class="queue-card"><h4>AI 视频</h4><p>最近 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "ai_video").length))} 条</p></div>
|
||
<div class="queue-card"><h4>内容源同步</h4><p>最近 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "content_source_sync").length))} 条</p></div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-top:14px;">
|
||
<span class="tag blue">分析 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "analysis").length))}</span>
|
||
<span class="tag">实拍 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "real_cut").length))}</span>
|
||
<span class="tag green">AI 视频 ${escapeHtml(formatNumber(jobs.filter((item) => item.line_type === "ai_video").length))}</span>
|
||
</div>
|
||
</div>
|
||
${renderQuotaBlockingNotice()}
|
||
<div class="panel pad" style="margin-top:18px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>生产工作区</h3>
|
||
<div class="panel-subtitle">把队列、恢复、录制和产物拆开看,减少一次性信息量。</div>
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前工作流</strong>
|
||
<span class="tag blue">${escapeHtml(tabs.find((tab) => tab.value === activeTab)?.label || "生产队列")}</span>
|
||
</div>
|
||
<p>${escapeHtml(
|
||
activeTab === "recovery"
|
||
? "先处理失败任务和可恢复项,再决定是否批量重开。"
|
||
: activeTab === "recorder"
|
||
? "先确认录制服务和文件状态,再回到队列继续推进。"
|
||
: activeTab === "outputs"
|
||
? "先看产物和作品,再决定是否回到复盘或继续生产。"
|
||
: "先看处理中任务,再把异常和产物安排到下一步。"
|
||
)}</p>
|
||
<div class="task-meta">
|
||
${activeTab === "recovery"
|
||
? `${actionTag("批量恢复", "batch-recover-jobs")} ${actionTag("查看恢复记录", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="recovery"`)}`
|
||
: activeTab === "outputs"
|
||
? `${actionTag("去复盘", "goto-review")} ${actionTag("查看产物", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="outputs"`)}`
|
||
: activeTab === "recorder"
|
||
? `${actionTag("录制维护", "select-page-tab", `data-page-tab-key="productionDetailTab" data-page-tab-value="recorder"`)} ${actionTag("交给主 Agent", "handoff-to-main-agent", productionHandoffAttrs)}`
|
||
: `${actionTag("批量恢复", "batch-recover-jobs")} ${actionTag("交给主 Agent", "handoff-to-main-agent", productionHandoffAttrs)}`
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-bottom:14px;">
|
||
<span class="tag blue">处理中 ${escapeHtml(formatNumber(activeJobs.length || jobs.filter((item) => item.status !== "completed").length))}</span>
|
||
<span class="tag ${failedJobs.length ? "red" : "green"}">失败 ${escapeHtml(formatNumber(failedJobs.length))}</span>
|
||
<span class="tag ${recoverableCount ? "orange" : "green"}">可恢复 ${escapeHtml(formatNumber(recoverableCount))}</span>
|
||
<span class="tag green">产物 ${escapeHtml(formatNumber(works.length))}</span>
|
||
</div>
|
||
${renderProductionMobileTaskDeck({
|
||
activeTab,
|
||
activeJobs,
|
||
failedJobs,
|
||
recoverableCount,
|
||
works,
|
||
recentDocs
|
||
})}
|
||
${renderDetailTabs("productionDetailTab", tabs)}
|
||
${activeTab === "queue" ? `
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>当前任务</h3><div class="panel-subtitle">来自 recent_jobs</div></div></div>
|
||
<div class="list">
|
||
${(activeJobs.length ? activeJobs : jobs.slice(0, 4)).map((job) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(job.title)}</h4>
|
||
<p>${escapeHtml(brief(job.style_summary || job.transcript_text || job.error || "暂无摘要", 80))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${statusTone(job.status)}">${escapeHtml(job.status)}</span>
|
||
<span class="tag">${escapeHtml(job.line_type || "analysis")}</span>
|
||
${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "做 AI 视频") : ""}
|
||
${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "做实拍剪辑") : ""}
|
||
${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item"><h4>还没有任务</h4><p>先去找对标导入内容。</p></div>`}
|
||
</div>
|
||
</div>
|
||
` : activeTab === "recovery" ? `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>失败任务恢复</h3>
|
||
<div class="panel-subtitle">把最近失败任务按恢复可行性分组,批量恢复入口在这里。</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag red">${escapeHtml(formatNumber(failedJobs.filter((item) => item.recovery.recoverable).length))} 可恢复</span>
|
||
<span class="tag orange">${escapeHtml(formatNumber(failedJobs.filter((item) => !item.recovery.recoverable).length))} 需人工</span>
|
||
<span class="tag clickable-tag" data-action="batch-recover-jobs">批量恢复</span>
|
||
</div>
|
||
</div>
|
||
<div class="list">
|
||
${failedJobs.map(({ job, recovery }) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(job.title || job.id)}</h4>
|
||
<p>${escapeHtml(brief(job.error || recovery.reason || "任务失败,请查看恢复说明。", 120))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${recovery.recoverable ? "green" : recovery.state === "blocked" ? "red" : "orange"}">${escapeHtml(recovery.label)}</span>
|
||
<span class="tag">${escapeHtml(job.line_type || job.source_type || "analysis")}</span>
|
||
${job.updated_at ? `<span class="tag">${escapeHtml(formatDateTime(job.updated_at))}</span>` : ""}
|
||
${recovery.recoverable ? `<span class="tag clickable-tag" data-action="recover-job" data-job-id="${escapeHtml(job.id)}">${escapeHtml(recovery.actionLabel)}</span>` : `<span class="tag ${recovery.state === "blocked" ? "red" : "orange"}">${escapeHtml(recovery.reason)}</span>`}
|
||
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(job.id)}">看详情</span>
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item"><h4>当前没有失败任务</h4><p>最近任务都在正常推进,暂时不需要恢复。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
${renderRecoveryHistoryPanel()}
|
||
</div>
|
||
</div>
|
||
` : activeTab === "recorder" ? `
|
||
<div style="margin-top:6px;">
|
||
${renderLiveRecorderManagementPanel()}
|
||
</div>
|
||
` : `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>作品与成片</h3><div class="panel-subtitle">先看真实作品,再补文档与成片</div></div></div>
|
||
<div class="list">
|
||
${works.map((video) => `
|
||
<div class="review-card compact">
|
||
<h4>${escapeHtml(describeVideo(video))}</h4>
|
||
<p>${escapeHtml(`发布时间 ${formatDateTime(video.published_at)} · 播放 ${formatNumber(video.stats?.play)} · 点赞 ${formatNumber(video.stats?.like)}`)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(video.content_type || "video")}</span>
|
||
<span class="tag green">得分 ${escapeHtml(formatNumber(video.score?.performance_score || 0))}</span>
|
||
${getVideoLink(video) ? `<a class="tag" href="${escapeHtml(getVideoLink(video))}" target="_blank" rel="noreferrer">打开原作品</a>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
${recentDocs.map((doc) => `
|
||
<div class="review-card compact">
|
||
<h4>${escapeHtml(doc.title)}</h4>
|
||
<p>${escapeHtml(brief(doc.style_summary || doc.combined_text || doc.transcript_text, 92))}</p>
|
||
<div class="task-meta"><span class="tag">${escapeHtml(doc.source_type || "document")}</span><span class="tag blue">学习素材</span></div>
|
||
</div>
|
||
`).join("") || (works.length ? "" : `<div class="review-card"><h4>还没有作品</h4><p>先导入内容或跑一次分析任务。</p></div>`)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
${renderLastJobDetailCard()}
|
||
</div>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderReviewScreen() {
|
||
if (!appState.dashboard) {
|
||
if (isAutoConnectionPending()) {
|
||
return renderAutoConnectingScreen("发布与复盘", "工作区就绪后,这里会自动生成复盘入口和最近完成任务。");
|
||
}
|
||
return screenShell("发布与复盘", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("复盘未加载", "自动连接成功后,这里会先用最近任务生成一版复盘入口。"));
|
||
}
|
||
const project = getSelectedProject();
|
||
const completed = safeArray(appState.dashboard.recent_jobs).filter((item) => item.status === "completed").slice(0, 4);
|
||
const reviews = getProjectReviews(project?.id || "").slice(0, 8);
|
||
const verdictCounts = reviews.reduce((acc, review) => {
|
||
const key = review?.verdict?.trim() || "待补结论";
|
||
acc[key] = (acc[key] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
const topVerdict = Object.entries(verdictCounts).sort((left, right) => right[1] - left[1])[0] || null;
|
||
const publishedReviewCount = reviews.filter((review) => review.publish_url || review.published_at).length;
|
||
const reviewTaskTitle = completed.length
|
||
? "先把最近完成任务写成复盘"
|
||
: reviews.length
|
||
? "先回看高频结论"
|
||
: "先跑出第一条可复盘任务";
|
||
const reviewTaskSummary = completed.length
|
||
? "最近已经有完成任务,先沉淀成结构化复盘,再决定是否回到生产继续放大。"
|
||
: reviews.length
|
||
? "当前项目已经有复盘沉淀,先看出现次数最多的结论,再决定下一步继续做什么。"
|
||
: "当前还没有复盘和完成任务,先去生产中心跑出一条完整链路。";
|
||
const reviewHandoffAttrs = buildMainAgentHandoffAttrs({
|
||
sourceScreen: "review",
|
||
sourceActionKey: "review-handoff",
|
||
intentKey: "review_followup",
|
||
title: project ? `继续沉淀项目复盘 · ${project.name}` : "继续沉淀复盘",
|
||
goal: "基于最近完成任务和已保存复盘,生成下一步复盘与发布计划",
|
||
summary: "主 Agent 会结合最近完成任务和现有复盘,整理下一步复盘动作。",
|
||
platform: getPreferredPlatform(),
|
||
platformScope: "single_platform",
|
||
planSteps: [
|
||
"读取最近完成任务和现有复盘",
|
||
"识别还缺的复盘或发布动作",
|
||
"生成下一步复盘推进计划"
|
||
]
|
||
});
|
||
return screenShell(
|
||
"发布与复盘",
|
||
"先看已保存复盘,再把完成任务转成结构化复盘。",
|
||
`${button("写复盘", "open-create-review")} ${button("刷新", "refresh-data")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: reviewHandoffAttrs })} ${button("去生产", "goto-production", "primary")}`,
|
||
`
|
||
${renderMainAgentLandingNotice("review")}
|
||
<div class="hero-card mobile-secondary-card">
|
||
<h3>复盘工作区</h3>
|
||
<p>${escapeHtml(project?.name || "当前项目")} · 先把完成任务沉淀成可追溯复盘,再决定是否回到生产继续推进。</p>
|
||
<div class="mini-grid">
|
||
<div class="mini-card"><small>已保存</small><strong>${escapeHtml(formatNumber(reviews.length))}</strong></div>
|
||
<div class="mini-card"><small>最近完成</small><strong>${escapeHtml(formatNumber(completed.length))}</strong></div>
|
||
<div class="mini-card"><small>当前项目</small><strong>${escapeHtml(project?.name || "未选项目")}</strong></div>
|
||
<div class="mini-card"><small>高频结论</small><strong>${escapeHtml(topVerdict ? topVerdict[0] : "待补结论")}</strong></div>
|
||
</div>
|
||
</div>
|
||
<div class="task-item" style="margin-top:16px;">
|
||
<h4>${escapeHtml(reviewTaskTitle)}</h4>
|
||
<p>${escapeHtml(reviewTaskSummary)}</p>
|
||
<div class="task-meta">
|
||
${actionTag(completed.length ? "写复盘" : reviews.length ? "看复盘" : "去生产", completed.length ? "open-review-from-job" : reviews.length ? "goto-review" : "goto-production", completed[0]?.id ? `data-job-id="${escapeHtml(completed[0].id)}"` : "")}
|
||
${actionTag("交给主 Agent", "handoff-to-main-agent", reviewHandoffAttrs)}
|
||
<span class="tag blue">${escapeHtml(`已保存 ${formatNumber(reviews.length)} 条`)}</span>
|
||
<span class="tag">${escapeHtml(`已发布 ${formatNumber(publishedReviewCount)} 条`)}</span>
|
||
<span class="tag">${escapeHtml(`高频结论 ${topVerdict ? topVerdict[0] : "待补"}`)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前复盘任务</strong>
|
||
<span class="tag blue">${escapeHtml(project?.name || "当前项目")}</span>
|
||
</div>
|
||
<p>${escapeHtml(
|
||
completed.length
|
||
? "先把最近完成任务写成复盘,再决定是否继续沉淀发布结论。"
|
||
: reviews.length
|
||
? "先回看已保存复盘,再决定是否回到生产继续推进。"
|
||
: "当前还没有可用复盘,先回到生产中心跑出一条完成链路。"
|
||
)}</p>
|
||
<div class="task-meta">
|
||
${actionTag(completed.length ? "写复盘" : "去生产", completed.length ? "open-review-from-job" : "goto-production", completed[0]?.id ? `data-job-id="${escapeHtml(completed[0].id)}"` : "")}
|
||
${actionTag("刷新", "refresh-data")}
|
||
${actionTag("交给主 Agent", "handoff-to-main-agent", reviewHandoffAttrs)}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-top:14px; margin-bottom:14px;">
|
||
<span class="tag blue">已保存 ${escapeHtml(formatNumber(reviews.length))}</span>
|
||
<span class="tag green">最近完成 ${escapeHtml(formatNumber(completed.length))}</span>
|
||
<span class="tag">${escapeHtml(`已发布 ${formatNumber(publishedReviewCount)}`)}</span>
|
||
<span class="tag">${escapeHtml(topVerdict ? `高频 ${topVerdict[0]}` : (completed.length ? "可继续写复盘" : "先回生产"))}</span>
|
||
</div>
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>已保存复盘</h3><div class="panel-subtitle">当前项目的真实复盘记录</div></div><span class="tag blue">${escapeHtml(formatNumber(reviews.length))} 条</span></div>
|
||
<div class="list">
|
||
${reviews.map((review) => `
|
||
<div class="review-card compact">
|
||
<h4>${escapeHtml(review.title)}</h4>
|
||
<p>${escapeHtml(brief(review.highlights || review.next_actions || review.notes || "已保存复盘,待继续补充表现数据。", 92))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(platformLabel(review.platform || "douyin"))}</span>
|
||
<span class="tag ${statusTone(review.verdict || "blue")}">${escapeHtml(review.verdict || "已记录")}</span>
|
||
${review.publish_url ? `<a class="tag" href="${escapeHtml(review.publish_url)}" target="_blank" rel="noreferrer">打开链接</a>` : ""}
|
||
<span class="tag clickable-tag" data-action="open-review-edit" data-review-id="${escapeHtml(review.id)}">编辑</span>
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="review-card"><h4>还没有复盘</h4><p>可以把最近完成任务直接写成一条复盘。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>最近完成</h3><div class="panel-subtitle">从完成任务继续写复盘或进入下一步生产</div></div></div>
|
||
<div class="list">
|
||
${completed.map((job) => `
|
||
<div class="review-card compact">
|
||
<h4>${escapeHtml(job.title)}</h4>
|
||
<p>${escapeHtml(brief(job.style_summary || job.transcript_text || "已完成,待补复盘。", 84))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag green">已完成</span>
|
||
<span class="tag">${escapeHtml(job.line_type || "analysis")}</span>
|
||
${actionTag("写复盘", "open-review-from-job", `data-job-id="${escapeHtml(job.id)}"`)}
|
||
${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "做 AI 视频") : ""}
|
||
${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "做实拍剪辑") : ""}
|
||
${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="review-card"><h4>还没有完成任务</h4><p>先去生产中心跑一条链路。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${renderLastJobDetailCard()}
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderStrategyScreen() {
|
||
if (!appState.dashboard) {
|
||
if (isAutoConnectionPending()) {
|
||
return renderAutoConnectingScreen("我的策略", "工作区就绪后,这里会自动展示你自己的策略层和治理记录。");
|
||
}
|
||
return screenShell("我的策略", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("策略未加载", "自动连接成功后,这里会展示你的全局策略、平台策略和治理记录。"));
|
||
}
|
||
const tabs = [
|
||
{ value: "effective", label: "当前生效" },
|
||
{ value: "global", label: "全局策略" },
|
||
{ value: "platform", label: "当前平台策略" },
|
||
{ value: "activity", label: "变更记录" }
|
||
];
|
||
const activeTab = getActiveDetailTab("strategyDetailTab", tabs);
|
||
const project = getSelectedProject();
|
||
const platform = appState.onelinerGovernanceEffective?.platform || appState.onelinerProfile?.default_platform || getPreferredPlatform();
|
||
const activeAdminOverrideNotice = appState.onelinerGovernanceEffective?.active_admin_override_notice || null;
|
||
const strategyHandoffAttrs = buildMainAgentHandoffAttrs({
|
||
sourceScreen: "strategy",
|
||
sourceActionKey: "strategy-main-agent-handoff",
|
||
intentKey: "custom",
|
||
title: "继续调整我的策略",
|
||
goal: "继续调整我的策略",
|
||
summary: "让主 Agent 结合当前生效层、个人策略和管理员覆盖,给出下一步治理建议。",
|
||
platform,
|
||
planSteps: ["读取当前生效策略", "检查用户层与管理员覆盖差异", "生成下一步治理建议"]
|
||
});
|
||
return screenShell(
|
||
"我的策略",
|
||
"把你和主 Agent 的对话沉淀成可查看、可回滚、可追溯的个人策略层。",
|
||
`${button("编辑全局策略", "open-user-global-policy")} ${button("编辑当前平台策略", "open-user-platform-policy", "primary")} ${button("交给主 Agent", "handoff-to-main-agent", "secondary", { attrs: strategyHandoffAttrs })}`,
|
||
`
|
||
${renderMainAgentLandingNotice("strategy")}
|
||
<div class="hero-card mobile-secondary-card">
|
||
<h3>当前策略工作区</h3>
|
||
<p>${escapeHtml(project?.name || "当前项目")} · ${escapeHtml(platformLabel(platform))}。这里展示系统默认、你的个性化策略和管理员覆盖是如何叠加生效的。</p>
|
||
${activeAdminOverrideNotice?.title ? `
|
||
<div class="task-item compact" style="margin-top:14px; border-color:rgba(245, 158, 11, 0.28); background:linear-gradient(180deg, rgba(255, 250, 240, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);">
|
||
<h4>管理员覆盖生效中</h4>
|
||
<p>${escapeHtml(activeAdminOverrideNotice.summary || "当前项目下的部分策略被管理员覆盖层托底。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag orange">${escapeHtml(activeAdminOverrideNotice.title || "管理员覆盖")}</span>
|
||
${activeAdminOverrideNotice.platform_label ? `<span class="tag">${escapeHtml(activeAdminOverrideNotice.platform_label)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
<div class="panel pad" style="margin-top:18px;">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>策略治理</h3>
|
||
<div class="panel-subtitle">先看当前生效,再回看你自己的历史和管理员覆盖,不必再通过多个弹窗来回切。</div>
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前策略任务</strong>
|
||
<span class="tag blue">${escapeHtml(tabs.find((tab) => tab.value === activeTab)?.label || "当前生效")}</span>
|
||
</div>
|
||
<p>${escapeHtml(
|
||
activeTab === "global"
|
||
? "先确认你的全局策略是否还贴合当前项目,再决定是否发布新版本。"
|
||
: activeTab === "platform"
|
||
? `先看 ${platformLabel(platform)} 当前平台策略,再决定是否只改这个平台。`
|
||
: activeTab === "activity"
|
||
? "先看最近变更和回滚,再决定是否继续交给主 Agent 调整。"
|
||
: activeAdminOverrideNotice?.title
|
||
? "先确认管理员覆盖为什么生效,再决定要不要继续改自己的策略。"
|
||
: "先看当前生效层,再决定调整全局还是当前平台。"
|
||
)}</p>
|
||
<div class="task-meta">
|
||
${activeTab === "global"
|
||
? `${actionTag("编辑全局策略", "open-user-global-policy")} ${actionTag("看全局历史", "open-user-global-policy-history")}`
|
||
: activeTab === "platform"
|
||
? `${actionTag("编辑当前平台策略", "open-user-platform-policy", `data-platform="${escapeHtml(platform)}"`)} ${actionTag("看平台历史", "open-user-platform-policy-history", `data-platform="${escapeHtml(platform)}"`)}`
|
||
: activeTab === "activity"
|
||
? `${actionTag("交给主 Agent", "handoff-to-main-agent", strategyHandoffAttrs)} ${actionTag("回当前生效", "select-page-tab", `data-page-tab-key="strategyDetailTab" data-page-tab-value="effective"`)}`
|
||
: `${actionTag("编辑全局策略", "open-user-global-policy")} ${actionTag("编辑当前平台策略", "open-user-platform-policy", `data-platform="${escapeHtml(platform)}"`)} ${actionTag("交给主 Agent", "handoff-to-main-agent", strategyHandoffAttrs)}`
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-bottom:14px;">
|
||
<span class="tag blue">当前生效 ${escapeHtml(formatNumber(safeArray(appState.onelinerGovernanceEffective?.layers).length))} 层</span>
|
||
<span class="tag">${escapeHtml(platformLabel(platform))} 平台策略</span>
|
||
<span class="tag green">变更 ${escapeHtml(formatNumber(safeArray(appState.userPolicyAudits).length))}</span>
|
||
<span class="tag ${activeAdminOverrideNotice?.title ? "orange" : "blue"}">${escapeHtml(activeAdminOverrideNotice?.title ? "管理员覆盖生效" : "无管理员覆盖")}</span>
|
||
</div>
|
||
${renderDetailTabs("strategyDetailTab", tabs)}
|
||
${activeTab === "effective" ? `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
${renderGovernanceSummaryCard({
|
||
title: "当前生效策略",
|
||
subtitle: appState.onelinerGovernanceEffective?.summary || "当前主 Agent 会按下面这些层级叠加执行。",
|
||
effective: appState.onelinerGovernanceEffective,
|
||
actions: [
|
||
{ action: "open-user-global-policy", label: "编辑我的全局策略" },
|
||
{ action: "open-user-platform-policy", label: "编辑当前平台策略", platform },
|
||
{
|
||
action: "handoff-to-main-agent",
|
||
label: "交给主 Agent 调整",
|
||
platform,
|
||
sourceScreen: "strategy",
|
||
sourceActionKey: "governance-summary-handoff",
|
||
intentKey: "custom",
|
||
title: "调整当前策略",
|
||
goal: "调整当前策略",
|
||
summary: "先由主 Agent 读取当前治理层,再给一版确认卡。",
|
||
planSteps: ["读取当前生效策略", "结合管理员覆盖与个人策略生成方案", "等待用户确认后执行"]
|
||
}
|
||
]
|
||
})}
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>当前叠加层</h3><div class="panel-subtitle">系统默认、用户层和管理员覆盖会按优先级叠加。</div></div></div>
|
||
<div class="list">
|
||
${safeArray(appState.onelinerGovernanceEffective?.layers).map((layer) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(policyScopeTagLabel(layer.scope_kind, layer.scope?.platform || platform))}</h4>
|
||
<p>${escapeHtml(layer.current_version?.summary || layer.scope?.summary || "当前层还没有补充摘要。")}</p>
|
||
<div class="task-meta">
|
||
${layer.current_version?.version_no ? `<span class="tag blue">版本 ${escapeHtml(formatNumber(layer.current_version.version_no || 0))}</span>` : ""}
|
||
${layer.scope?.platform ? `<span class="tag">${escapeHtml(platformLabel(layer.scope.platform))}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>还没有策略层</h4><p>当前会话还没有拉到治理层信息。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : activeTab === "global" ? `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>我的全局策略</h3><div class="panel-subtitle">这层只影响你自己,会先于平台策略被主 Agent 读取。</div></div></div>
|
||
${renderPolicyVersionSummary(appState.userGlobalPolicy || {}, "你还没有发布自己的全局策略。")}
|
||
<div class="task-meta" style="margin-top:12px;">
|
||
<span class="tag clickable-tag" data-action="open-user-global-policy">编辑</span>
|
||
<span class="tag clickable-tag" data-action="open-user-global-policy-history">历史与回滚</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>最近全局版本</h3><div class="panel-subtitle">你的全局策略回滚不会覆盖旧记录,而是生成一个新的版本。</div></div></div>
|
||
<div class="list">${renderPolicyVersionsHtml(appState.userGlobalPolicy?.versions?.items || appState.userGlobalPolicy?.versions || [], "你的全局策略还没有历史版本。")}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : activeTab === "platform" ? `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>${escapeHtml(platformLabel(platform))} 当前平台策略</h3><div class="panel-subtitle">只影响当前平台,不会连带改动其他平台。</div></div></div>
|
||
${renderPolicyVersionSummary(appState.userCurrentPlatformPolicy || {}, `你还没有发布 ${platformLabel(platform)} 平台策略。`)}
|
||
<div class="task-meta" style="margin-top:12px;">
|
||
<span class="tag clickable-tag" data-action="open-user-platform-policy" data-platform="${escapeHtml(platform)}">编辑</span>
|
||
<span class="tag clickable-tag" data-action="open-user-platform-policy-history" data-platform="${escapeHtml(platform)}">历史与回滚</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>最近平台版本</h3><div class="panel-subtitle">当前平台的个性化策略会覆盖你的全局策略,但仍然只对你自己生效。</div></div></div>
|
||
<div class="list">${renderPolicyVersionsHtml(appState.userCurrentPlatformPolicy?.versions?.items || appState.userCurrentPlatformPolicy?.versions || [], `${platformLabel(platform)} 还没有历史版本。`)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>最近策略变更</h3><div class="panel-subtitle">你的发布、回滚,以及管理员对当前项目的覆盖动作都会留痕在这里。</div></div></div>
|
||
<div class="list">${renderPolicyAuditFeed(appState.userPolicyAudits, "当前项目还没有治理记录。")}</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>治理提醒</h3><div class="panel-subtitle">让你快速判断当前是该继续微调,还是该让管理员介入。</div></div></div>
|
||
<div class="list">
|
||
<div class="task-item compact">
|
||
<h4>用户层不会影响其他人</h4>
|
||
<p>你发布或回滚自己的策略,只会影响你当前账户下的工作方式。</p>
|
||
</div>
|
||
<div class="task-item compact">
|
||
<h4>管理员覆盖会明确可见</h4>
|
||
<p>如果管理员对你当前项目施加了覆盖层,这里会出现对应的记录与摘要。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderCreditsScreen() {
|
||
if (!appState.dashboard) {
|
||
if (isAutoConnectionPending()) {
|
||
return renderAutoConnectingScreen("额度", "工作区就绪后,这里会自动展示真实额度和运营看板。");
|
||
}
|
||
return screenShell("额度", "先自动连接工作区。", `${button("自动连接", "open-auth", "primary")}`, renderEmptyState("额度未加载", "自动连接成功后,这里会展示真实额度和运营看板。"));
|
||
}
|
||
const jobs = safeArray(appState.dashboard.recent_jobs);
|
||
const quota = appState.tenantQuota;
|
||
const usage = appState.tenantUsage || quota?.usage || {};
|
||
const categories = usage?.categories || {};
|
||
const estimatedVideoUsage = (categories.ai_video?.quantity || 0) + (categories.real_cut?.quantity || 0);
|
||
const budgetAmount = (quota?.monthly_budget_cents || usage?.total_cost_cents || 0) / 100;
|
||
const usedAmount = (usage?.total_cost_cents || 0) / 100;
|
||
const quotaProtectionLabel = quota?.enabled === false ? "额度保护关闭" : "额度保护开启";
|
||
const riskLabel = quota?.storage_over_limit ? "存储超限" : "当前风险可控";
|
||
return screenShell(
|
||
"额度",
|
||
"在接真实计费前,先按任务量给出运营看板。",
|
||
`${button("刷新", "refresh-data")}`,
|
||
`
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前额度任务</strong>
|
||
<span class="tag blue">${escapeHtml(quotaProtectionLabel)}</span>
|
||
</div>
|
||
<p>${escapeHtml(
|
||
quota
|
||
? `先确认预算 ${formatNumber(budgetAmount)} 元和已用 ${formatNumber(usedAmount)} 元,再决定是继续放量还是先收紧高成本动作。`
|
||
: "先看预算和高成本动作的预估消耗,再决定是否要继续做视频或剪辑任务。"
|
||
)}</p>
|
||
<div class="task-meta">
|
||
${actionTag("刷新额度", "refresh-data")}
|
||
${actionTag("去生产中心", "goto-production")}
|
||
${actionTag("交给主 Agent", "handoff-to-main-agent", buildMainAgentHandoffAttrs({
|
||
sourceScreen: "credits",
|
||
sourceActionKey: "credits-main-agent-handoff",
|
||
intentKey: "custom",
|
||
title: "评估当前额度和预算风险",
|
||
goal: "评估当前额度和预算风险",
|
||
summary: "让主 Agent 结合当前预算、已用额度和高成本动作,给出下一步建议。",
|
||
planSteps: ["读取当前额度看板", "判断预算与高成本动作风险", "给出下一步控制建议"]
|
||
}))}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-bottom:14px;">
|
||
<span class="tag blue">预算 ${escapeHtml(formatNumber(budgetAmount))} 元</span>
|
||
<span class="tag ${quota?.storage_over_limit ? "orange" : "green"}">${escapeHtml(riskLabel)}</span>
|
||
<span class="tag">${escapeHtml(formatNumber(categories.copy?.quantity || jobs.filter((item) => item.line_type === "analysis").length))} 条文案</span>
|
||
<span class="tag">${escapeHtml(formatNumber(estimatedVideoUsage || jobs.filter((item) => item.line_type === "ai_video" || item.line_type === "real_cut").length))} 次视频</span>
|
||
</div>
|
||
<div class="layout-grid grid-3">
|
||
<div class="stat-card"><small>文案消耗预估</small><strong>${escapeHtml(formatNumber(categories.copy?.quantity || jobs.filter((item) => item.line_type === "analysis").length))}</strong><div class="stat-foot"><span>分析 / 生成链路</span><span class="positive">按任务量估算</span></div></div>
|
||
<div class="stat-card"><small>本周期预算</small><strong>${escapeHtml(formatNumber((quota?.monthly_budget_cents || usage?.total_cost_cents || 0) / 100))}</strong><div class="stat-foot"><span>元</span><span class="warn">已用 ${escapeHtml(formatNumber((usage?.total_cost_cents || 0) / 100))} 元</span></div></div>
|
||
<div class="stat-card"><small>视频消耗预估</small><strong>${escapeHtml(formatNumber(estimatedVideoUsage || jobs.filter((item) => item.line_type === "ai_video" || item.line_type === "real_cut").length))}</strong><div class="stat-foot"><span>AI 视频 / 实拍剪辑</span><span class="positive">可做套餐</span></div></div>
|
||
</div>
|
||
<div class="layout-grid grid-main" style="margin-top:18px;">
|
||
<div class="side-stack">
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>当前额度策略</h3><div class="panel-subtitle">先让用户能看懂“还剩多少、风险在哪、下一步怎么做”。</div></div></div>
|
||
<div class="list">
|
||
<div class="task-item">
|
||
<h4>预算与已用</h4>
|
||
<p>${escapeHtml(quota ? `当前预算 ${formatNumber((quota.monthly_budget_cents || 0) / 100)} 元,已用 ${formatNumber((usage?.total_cost_cents || 0) / 100)} 元。` : "后端尚未完全接入真实预算,当前先按任务量做用户可理解的额度看板。")}</p>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>动作额度</h4>
|
||
<p>${escapeHtml(quota ? `文案 ${formatNumber(quota.copy_quota || 0)} / AI 视频 ${formatNumber(quota.ai_video_quota || 0)} / 实拍剪辑 ${formatNumber(quota.real_cut_quota || 0)}。` : "当前优先展示文案、封面、视频三类额度池,后续再接真实套餐。")}</p>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>使用建议</h4>
|
||
<p>${escapeHtml((quota?.enabled === false) ? "当前额度保护已关闭,适合内部联调,不适合正式对外。":"当前额度保护已开启,适合逐步转向对外产品表达。")}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad">
|
||
<div class="panel-head"><div><h3>用户能理解的表达</h3><div class="panel-subtitle">不要只给数字,要给解释和风险提示。</div></div></div>
|
||
<div class="list">
|
||
<div class="review-card">
|
||
<h4>文案额度</h4>
|
||
<p>适合高频迭代,建议和项目阶段绑定,而不是裸数字展示。</p>
|
||
</div>
|
||
<div class="review-card">
|
||
<h4>视频额度</h4>
|
||
<p>成本更高,适合明确写出已用、剩余和推荐使用场景。</p>
|
||
</div>
|
||
<div class="review-card">
|
||
<h4>风险提示</h4>
|
||
<p>${escapeHtml(quota?.storage_over_limit ? "当前存储已超限,后续应优先处理清理或扩容。" : "当前没有明显超限风险,但仍建议补齐真实计费链路。")}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderSettingsScreen() {
|
||
const session = appState.session;
|
||
const project = getSelectedProject();
|
||
const tabs = [
|
||
{ value: "workspace", label: "连接与工作区" },
|
||
{ value: "display", label: "界面与帮助" }
|
||
];
|
||
const activeTab = getActiveDetailTab("settingsDetailTab", tabs);
|
||
const sessionConnected = Boolean(session);
|
||
const settingsHandoffAttrs = buildMainAgentHandoffAttrs({
|
||
sourceScreen: "settings",
|
||
sourceActionKey: "settings-main-agent-handoff",
|
||
intentKey: "custom",
|
||
title: "检查当前设置和工作区连接",
|
||
goal: "检查当前设置和工作区连接",
|
||
summary: "让主 Agent 读取当前连接状态、工作区和常用入口,再给出下一步建议。",
|
||
planSteps: ["读取当前连接与项目上下文", "确认当前工作区和入口状态", "生成下一步建议"]
|
||
});
|
||
return screenShell(
|
||
"设置",
|
||
"这里不放系统治理内容,只处理当前用户需要理解的连接、界面和帮助信息。",
|
||
`${isSuperAdmin() ? button("管理员配置台", "goto-admin-workbench", "primary") : button("刷新", "refresh-data", "primary")}`,
|
||
`
|
||
<div class="hero-card mobile-secondary-card">
|
||
<h3>设置与帮助</h3>
|
||
<p>把连接状态、当前工作区和使用说明放在一起,避免和管理员控制面混在同一页。</p>
|
||
</div>
|
||
<div class="panel pad" style="margin-top:18px;">
|
||
<div class="panel-head"><div><h3>当前设置</h3><div class="panel-subtitle">先看你现在接到了哪里,再决定是否要调整。</div></div></div>
|
||
<div class="mobile-only mobile-flow-focus-card">
|
||
<div class="mobile-flow-focus-head">
|
||
<strong>当前设置任务</strong>
|
||
<span class="tag ${sessionConnected ? "green" : "red"}">${escapeHtml(sessionConnected ? "已自动连接" : "等待连接")}</span>
|
||
</div>
|
||
<p>${escapeHtml(
|
||
sessionConnected
|
||
? `当前已经连到 ${session?.account?.display_name || session?.account?.username || "当前工作区"},先确认工作区和常用入口,再决定是否切项目或回到业务页。`
|
||
: "先确认当前站点是否已自动连接到工作区,再决定是重试连接还是回到业务页。"
|
||
)}</p>
|
||
<div class="task-meta">
|
||
${actionTag("打开连接状态", "open-auth")}
|
||
${project ? actionTag("去我的项目", "goto-intake") : ""}
|
||
${actionTag("交给主 Agent", "handoff-to-main-agent", settingsHandoffAttrs)}
|
||
</div>
|
||
</div>
|
||
<div class="mobile-only compact-summary-row" style="margin-bottom:14px;">
|
||
<span class="tag ${sessionConnected ? "green" : "red"}">${escapeHtml(sessionConnected ? "已自动连接" : "等待连接")}</span>
|
||
<span class="tag blue">${escapeHtml(project?.name || "未选项目")}</span>
|
||
<span class="tag">${escapeHtml(formatBackendDisplayLabel(session?.backendUrl || DEFAULT_BACKEND_URL))}</span>
|
||
<span class="tag">${escapeHtml(activeTab === "workspace" ? "连接与工作区" : "界面与帮助")}</span>
|
||
</div>
|
||
${renderDetailTabs("settingsDetailTab", tabs)}
|
||
${activeTab === "workspace" ? `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>当前连接</h3><div class="panel-subtitle">当前前端与后端的真实连接状态。</div></div></div>
|
||
<div class="list">
|
||
<div class="task-item">
|
||
<h4>${escapeHtml(session?.account?.display_name || session?.account?.username || "未连接")}</h4>
|
||
<p>${escapeHtml(session?.backendUrl || DEFAULT_BACKEND_URL)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${session ? "green" : "red"}">${escapeHtml(session ? "已自动连接" : "等待连接")}</span>
|
||
${project ? `<span class="tag blue">${escapeHtml(project.name)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>自动连接说明</h4>
|
||
<p>当前站点不会要求用户手输账号密码,而是直接向固定后端请求自动会话。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>快捷入口</h3><div class="panel-subtitle">从设置页快速回到最常用的工作区。</div></div></div>
|
||
<div class="list">
|
||
<div class="task-item">
|
||
<h4>项目与 Agent</h4>
|
||
<p>如果你想切项目、看默认 Agent 或确认当前工作上下文,可以直接从这里回去。</p>
|
||
<div class="task-meta">
|
||
${actionTag("去我的项目", "goto-intake")}
|
||
${actionTag("去 Agent", "goto-playbook")}
|
||
</div>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>生产与跟踪</h4>
|
||
<p>如果你更关心最近任务和跟踪动态,可以直接回到这两个工作页。</p>
|
||
<div class="task-meta">
|
||
${actionTag("去生产中心", "goto-production")}
|
||
${actionTag("去跟踪账号", "goto-tracking")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : `
|
||
<div class="layout-grid grid-main">
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>界面原则</h3><div class="panel-subtitle">帮助用户理解当前产品的使用方式。</div></div></div>
|
||
<div class="list">
|
||
<div class="task-item"><h4>首页优先看动作</h4><p>首页只负责“今天先做什么”,深层信息回到各工作页处理。</p></div>
|
||
<div class="task-item"><h4>单页只做一类决策</h4><p>重页面已经收成页内 tab,避免一次看到太多层信息。</p></div>
|
||
<div class="task-item"><h4>系统治理不混进用户页</h4><p>管理员相关配置统一收口到管理员配置台。</p></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-stack">
|
||
<div class="panel pad" style="box-shadow:none;">
|
||
<div class="panel-head"><div><h3>帮助入口</h3><div class="panel-subtitle">用户先看到帮助和定位,不直接掉进系统控制面。</div></div></div>
|
||
<div class="list">
|
||
<div class="task-item">
|
||
<h4>连接状态</h4>
|
||
<p>如果页面看起来没有数据,先确认当前工作区是否已自动连接。</p>
|
||
<div class="task-meta">${actionTag("打开连接状态", "open-auth")}</div>
|
||
</div>
|
||
${isSuperAdmin() ? `
|
||
<div class="task-item">
|
||
<h4>管理员配置台</h4>
|
||
<p>系统依赖、存储、平台 Agent 和运维审计都在管理员配置台里,不放在普通用户页。</p>
|
||
<div class="task-meta">${actionTag("去管理员配置台", "goto-admin-workbench")}</div>
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`
|
||
);
|
||
}
|
||
|
||
function renderTopbar() {
|
||
const workspaceStrong = document.querySelector(".workspace-switch strong");
|
||
const workspaceSpan = document.querySelector(".workspace-switch span");
|
||
const mobileWorkspaceProject = document.querySelector('[data-role="mobile-workspace-project"]');
|
||
const mobileWorkspacePlatforms = document.querySelector('[data-role="mobile-workspace-platforms"]');
|
||
const searchInput = document.querySelector(".search input");
|
||
const avatar = document.querySelector(".avatar");
|
||
const topPills = document.querySelectorAll(".top-pill");
|
||
const platforms = document.querySelector(".topbar-left .chip-row");
|
||
const project = getSelectedProject();
|
||
const currentPlatform = getCurrentPlatformValue();
|
||
const connectedLabel = appState.busy ? "同步中" : (appState.session ? "已连接" : "待连接");
|
||
if (workspaceStrong) {
|
||
workspaceStrong.textContent = project?.name || (appState.session ? "已连接工作区" : "未连接工作区");
|
||
}
|
||
if (workspaceSpan) {
|
||
workspaceSpan.textContent = appState.dashboard
|
||
? `${safeArray(appState.dashboard.projects).length} 个项目 · ${safeArray(appState.dashboard.assistants).length} 个 Agent`
|
||
: "连接后加载项目和 Agent";
|
||
}
|
||
if (searchInput) {
|
||
searchInput.value = "";
|
||
searchInput.placeholder = "搜项目、账号、内容、Agent";
|
||
}
|
||
if (avatar) {
|
||
avatar.textContent = initials(appState.me?.display_name || appState.me?.username || appState.session?.account?.display_name || "SF");
|
||
}
|
||
if (topPills.length >= 3) {
|
||
topPills[0].textContent = `项目 ${formatNumber(appState.dashboard?.projects?.length || 0)}`;
|
||
topPills[1].textContent = `对标 ${formatNumber(appState.accounts.length)}`;
|
||
topPills[2].textContent = `任务 ${formatNumber(appState.dashboard?.recent_jobs?.length || 0)}`;
|
||
}
|
||
if (mobileWorkspaceProject) {
|
||
mobileWorkspaceProject.dataset.action = "open-dashboard-project-switcher";
|
||
mobileWorkspaceProject.innerHTML = `
|
||
<span class="mobile-workspace-project-label">当前项目</span>
|
||
<strong>${escapeHtml(project?.name || (appState.session ? "已连接工作区" : "未连接工作区"))}</strong>
|
||
`;
|
||
}
|
||
if (mobileWorkspacePlatforms) {
|
||
mobileWorkspacePlatforms.innerHTML = getPlatformOptions().map((item) => `
|
||
<button
|
||
class="chip clickable-tag ${item.value === currentPlatform ? "active" : ""}"
|
||
type="button"
|
||
data-action="select-platform"
|
||
data-platform="${escapeHtml(item.value)}"
|
||
>
|
||
${escapeHtml(getPlatformShortLabel(item.value))}
|
||
</button>
|
||
`).join("");
|
||
}
|
||
if (platforms) {
|
||
platforms.innerHTML = [
|
||
`<span class="chip">已接入平台</span>`,
|
||
...getPlatformOptions().map((item) => `
|
||
<span class="chip clickable-tag ${item.value === currentPlatform ? "active" : ""}" data-action="select-platform" data-platform="${escapeHtml(item.value)}">
|
||
${escapeHtml(getPlatformShortLabel(item.value))}
|
||
</span>
|
||
`)
|
||
].join("");
|
||
}
|
||
}
|
||
|
||
function syncRoleGatedNav() {
|
||
const allowAdmin = isSuperAdmin();
|
||
document.querySelectorAll("[data-role-gate]").forEach((element) => {
|
||
const gate = element.getAttribute("data-role-gate");
|
||
const visible = gate === "super_admin" ? allowAdmin : true;
|
||
element.classList.toggle("hidden", !visible);
|
||
element.hidden = !visible;
|
||
});
|
||
if (!allowAdmin && appState.screen === "admin-workbench") {
|
||
appState.screen = "dashboard";
|
||
}
|
||
}
|
||
|
||
function renderAll() {
|
||
renderTopbar();
|
||
renderAuthUi();
|
||
syncRoleGatedNav();
|
||
screenMap.dashboard.innerHTML = renderDashboardScreen();
|
||
screenMap.intake.innerHTML = renderProjectsScreen();
|
||
screenMap.discovery.innerHTML = renderDiscoveryScreen();
|
||
screenMap.tracking.innerHTML = renderTrackingScreen();
|
||
screenMap.automation.innerHTML = renderAutomationScreen();
|
||
screenMap.owned.innerHTML = renderOwnedScreen();
|
||
screenMap.playbook.innerHTML = renderPlaybookScreen();
|
||
if (screenMap.strategy) {
|
||
screenMap.strategy.innerHTML = renderStrategyScreen();
|
||
}
|
||
screenMap.production.innerHTML = renderProductionScreen();
|
||
screenMap.review.innerHTML = renderReviewScreen();
|
||
screenMap.credits.innerHTML = renderCreditsScreen();
|
||
if (screenMap.settings) {
|
||
screenMap.settings.innerHTML = renderSettingsScreen();
|
||
}
|
||
if (screenMap["admin-workbench"]) {
|
||
screenMap["admin-workbench"].innerHTML = renderAdminWorkbenchScreen();
|
||
}
|
||
renderOneLinerUi();
|
||
setScreen(screenMap[appState.screen] ? appState.screen : "dashboard");
|
||
}
|
||
|
||
async function createProject() {
|
||
if (!appState.session) {
|
||
openAuthModal();
|
||
return;
|
||
}
|
||
const existingProjects = safeArray(appState.dashboard?.projects);
|
||
openActionModal({
|
||
title: "新建项目",
|
||
description: "先把项目建起来,首页动作、主 Agent 和记忆都会跟着这个项目继续往下走。",
|
||
submitLabel: "创建并进入项目",
|
||
fields: [
|
||
{
|
||
name: "projectGuide",
|
||
label: "创建提示",
|
||
type: "html",
|
||
html: `
|
||
<div class="task-item compact">
|
||
<h4>先建一个可继续推进的项目</h4>
|
||
<p>推荐直接按你现在的业务目标来命名,例如某个平台增长、某个账号矩阵或某个内容专题。</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">当前已有 ${escapeHtml(formatNumber(existingProjects.length))} 个项目</span>
|
||
<span class="tag">创建后会自动切过去</span>
|
||
<span class="tag">主 Agent 会沿用这个项目上下文</span>
|
||
</div>
|
||
</div>
|
||
`
|
||
},
|
||
{ name: "name", label: "项目名称", placeholder: "例如:创业 IP 增长实验室" },
|
||
{ name: "description", label: "项目说明", type: "textarea", rows: 4, placeholder: "写一句这个项目主要解决什么问题、接下来准备推进什么" }
|
||
],
|
||
onOpen: ({ fields }) => {
|
||
fields.querySelector('[data-action-field="name"]')?.focus();
|
||
},
|
||
onSubmit: async (values) => {
|
||
const name = String(values.name || "").trim();
|
||
if (!name) throw new Error("请填写项目名称");
|
||
setBusy(true, "正在创建项目...");
|
||
try {
|
||
const project = await storyforgeFetch("/v2/projects", {
|
||
method: "POST",
|
||
body: {
|
||
name,
|
||
description: String(values.description || "").trim()
|
||
}
|
||
});
|
||
appState.selectedProjectId = project.id || appState.selectedProjectId;
|
||
rememberAction("项目已创建", `已创建「${project.name || name}」,并切到这个项目继续推进。`, "green", project);
|
||
await bootstrap();
|
||
} catch (error) {
|
||
presentActionFailure(error, "创建项目失败");
|
||
throw error;
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function openPreferredModelAction() {
|
||
const models = getModelOptions();
|
||
const currentProfile = getCurrentModelProfile();
|
||
const currentId = currentProfile?.id || models[0]?.value || "";
|
||
const localCatalog = appState.localModelCatalog || {};
|
||
const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean);
|
||
openActionModal({
|
||
title: "设置分析主模型",
|
||
description: "后续导入分析、市场调研和风格学习会优先使用这里设置的模型。",
|
||
submitLabel: "保存模型",
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "本机模型网关",
|
||
html: `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(localCatalog.reachable ? "网关在线" : "网关离线")}</h4>
|
||
<p>${escapeHtml(currentProfile ? `当前主模型:${currentProfile.name} · ${currentProfile.model_name || "-"}` : `默认模型:${localCatalog.default_model || "GLM-5"}`)}</p>
|
||
<div class="task-meta">
|
||
${gatewayModels.slice(0, 6).map((model) => `<span class="tag">${escapeHtml(model)}</span>`).join("") || `<span class="tag red">暂未读取到模型目录</span>`}
|
||
${localCatalog.management_url ? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>` : ""}
|
||
</div>
|
||
</div>
|
||
`
|
||
},
|
||
{ name: "modelProfileId", label: "主模型", type: "select", value: currentId, options: models }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.modelProfileId) throw new Error("请先选择一个模型");
|
||
await storyforgeFetch("/v2/me/preferences/analysis-model", {
|
||
method: "POST",
|
||
body: { model_profile_id: values.modelProfileId }
|
||
});
|
||
rememberAction("主模型已更新", "新的分析主模型已经保存。", "green");
|
||
await bootstrap();
|
||
}
|
||
});
|
||
}
|
||
|
||
function rememberAction(title, summary, tone = "blue", payload = null) {
|
||
appState.lastAction = {
|
||
title,
|
||
summary,
|
||
tone,
|
||
payload,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
}
|
||
|
||
function extractGeneratedCopy(payload) {
|
||
const raw = payload?.content || payload?.text || payload?.copy || payload?.result?.content || "";
|
||
return brief(raw, 2400);
|
||
}
|
||
|
||
function renderLastActionCard() {
|
||
if (!appState.lastAction) return "";
|
||
const payload = appState.lastAction.payload || {};
|
||
const recommendedAction = payload?.result?.recommended_action || payload?.recommended_action || null;
|
||
const runId = payload?.id || payload?.run_id || "";
|
||
const landingAttrs = buildMainAgentLandingAttrs({
|
||
runId,
|
||
screen: recommendedAction?.screen || "",
|
||
title: appState.lastAction.title || "",
|
||
summary: recommendedAction?.summary || appState.lastAction.summary || ""
|
||
});
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>最近动作</h3>
|
||
<div class="panel-subtitle">${escapeHtml(formatDateTime(appState.lastAction.createdAt))}</div>
|
||
</div>
|
||
<span class="tag ${escapeHtml(appState.lastAction.tone || "blue")}">${escapeHtml(appState.lastAction.title)}</span>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>${escapeHtml(appState.lastAction.title)}</h4>
|
||
<p>${escapeHtml(appState.lastAction.summary)}</p>
|
||
${(runId || recommendedAction?.action) ? `
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
${runId ? `<span class="tag clickable-tag" data-action="open-oneliner-run-result" data-run-id="${escapeHtml(runId)}">查看结果</span>` : ""}
|
||
${recommendedAction?.action ? `<span class="tag clickable-tag" data-action="${escapeHtml(recommendedAction.action)}" ${landingAttrs}>${escapeHtml(recommendedAction.label || "回到对应页面")}</span>` : ""}
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function getJobRecoveryCategory(job) {
|
||
const sourceType = String(job?.source_type || "").toLowerCase();
|
||
const lineType = String(job?.line_type || "").toLowerCase();
|
||
if (sourceType === "text" || lineType === "analysis") return "analysis";
|
||
if (sourceType === "video_link") return "analysis";
|
||
if (sourceType === "content_source_sync") return "analysis";
|
||
if (sourceType === "upload_video") return "analysis";
|
||
if (lineType === "copy") return "copy";
|
||
if (lineType === "ai_video" || sourceType === "ai_video") return "ai_video";
|
||
if (lineType === "real_cut" || sourceType === "real_cut") return "real_cut";
|
||
if (lineType === "live_recorder" || sourceType === "live_recorder") return "recorder";
|
||
return "";
|
||
}
|
||
|
||
function getQuotaSummaryForCategory(category) {
|
||
const quota = appState.tenantQuota || {};
|
||
const usage = appState.tenantUsage || quota?.usage || {};
|
||
if (!quota || quota.enabled === false || !category) {
|
||
return { enabled: true, blocked: false, reason: "", usage, quota };
|
||
}
|
||
const categoryQuotaField = {
|
||
analysis: "analysis_quota",
|
||
copy: "copy_quota",
|
||
ai_video: "ai_video_quota",
|
||
real_cut: "real_cut_quota",
|
||
recorder: "recorder_quota"
|
||
}[category];
|
||
const categoryUsage = usage?.categories?.[category] || {};
|
||
const consumed = Number(categoryUsage.quantity || 0);
|
||
const allowed = Number(quota[categoryQuotaField] || 0);
|
||
if (allowed && consumed >= allowed) {
|
||
return {
|
||
enabled: true,
|
||
blocked: true,
|
||
reason: `当前租户本周期的 ${category} 配额已用完`,
|
||
usage,
|
||
quota
|
||
};
|
||
}
|
||
const budget = Number(quota.monthly_budget_cents || 0);
|
||
const totalCost = Number(usage.total_cost_cents || 0);
|
||
const categoryCost = {
|
||
analysis: 6,
|
||
copy: 3,
|
||
ai_video: 30,
|
||
real_cut: 20,
|
||
recorder: 2
|
||
}[category] || 0;
|
||
if (budget && totalCost + categoryCost > budget) {
|
||
return {
|
||
enabled: true,
|
||
blocked: true,
|
||
reason: "当前租户本周期预算不足,已阻止本次动作执行",
|
||
usage,
|
||
quota
|
||
};
|
||
}
|
||
if (quota.storage_over_limit && ["analysis", "ai_video", "real_cut"].includes(category)) {
|
||
return {
|
||
enabled: true,
|
||
blocked: true,
|
||
reason: "当前租户存储额度已满,已阻止继续产生大文件缓存",
|
||
usage,
|
||
quota
|
||
};
|
||
}
|
||
return { enabled: true, blocked: false, reason: "", usage, quota };
|
||
}
|
||
|
||
function getJobRecoverability(job) {
|
||
const status = String(job?.status || "").toLowerCase();
|
||
const sourceType = String(job?.source_type || "").toLowerCase();
|
||
const lineType = String(job?.line_type || "").toLowerCase();
|
||
const category = getJobRecoveryCategory(job);
|
||
const quotaGuard = getQuotaSummaryForCategory(category);
|
||
const sourceJobId = String(job?.artifacts?.source_job_id || job?.result?.source_job_id || job?.parent_job_id || "").trim();
|
||
const sourceAccountUrl = String(job?.artifacts?.source_account_url || job?.source_url || "").trim();
|
||
const uploadedPath = String(job?.artifacts?.uploaded_path || "").trim();
|
||
const cutvideoRequest = job?.artifacts?.cutvideo_request || {};
|
||
const base = {
|
||
status,
|
||
sourceType,
|
||
lineType,
|
||
category,
|
||
quotaGuard,
|
||
sourceJobId,
|
||
sourceAccountUrl,
|
||
uploadedPath,
|
||
cutvideoRequest
|
||
};
|
||
if (!job?.id) {
|
||
return {
|
||
...base,
|
||
state: "missing",
|
||
label: "未加载",
|
||
reason: "当前任务详情还没有加载完成。",
|
||
recoverable: false,
|
||
actionLabel: "刷新后再试",
|
||
actionKey: "refresh-data"
|
||
};
|
||
}
|
||
if (["completed", "done", "succeeded"].includes(status)) {
|
||
return {
|
||
...base,
|
||
state: "completed",
|
||
label: "已完成",
|
||
reason: "这条任务已经完成,不需要恢复。",
|
||
recoverable: false,
|
||
actionLabel: "查看详情",
|
||
actionKey: "open-job-detail"
|
||
};
|
||
}
|
||
if (status !== "failed") {
|
||
return {
|
||
...base,
|
||
state: "active",
|
||
label: "运行中",
|
||
reason: "这条任务还没有失败,先等待当前流程跑完。",
|
||
recoverable: false,
|
||
actionLabel: "继续观察",
|
||
actionKey: "open-job-detail"
|
||
};
|
||
}
|
||
if (quotaGuard.blocked) {
|
||
return {
|
||
...base,
|
||
state: "blocked",
|
||
label: "额度拦截",
|
||
reason: quotaGuard.reason,
|
||
recoverable: false,
|
||
actionLabel: "先补额度",
|
||
actionKey: "open-tenant-quota"
|
||
};
|
||
}
|
||
if (sourceType === "upload_video") {
|
||
if (uploadedPath) {
|
||
return {
|
||
...base,
|
||
state: "recoverable",
|
||
label: "可恢复",
|
||
reason: "上传素材仍可复用,可以直接重新入队这条分析任务。",
|
||
recoverable: true,
|
||
actionLabel: "恢复上传分析",
|
||
actionKey: "recover-job"
|
||
};
|
||
}
|
||
return {
|
||
...base,
|
||
state: "manual",
|
||
label: "需补素材",
|
||
reason: uploadedPath
|
||
? "这条上传任务需要重新上传原始素材后再恢复。"
|
||
: "这条任务缺少可复用的上传素材,需要先补回原文件。",
|
||
recoverable: false,
|
||
actionLabel: "重新上传",
|
||
actionKey: "open-upload-video"
|
||
};
|
||
}
|
||
if (lineType === "real_cut") {
|
||
if (!sourceJobId) {
|
||
return {
|
||
...base,
|
||
state: "manual",
|
||
label: "缺少源任务",
|
||
reason: "实拍剪辑缺少源任务,暂时无法自动恢复。",
|
||
recoverable: false,
|
||
actionLabel: "看源任务",
|
||
actionKey: "open-job-detail"
|
||
};
|
||
}
|
||
return {
|
||
...base,
|
||
state: "recoverable",
|
||
label: "可恢复",
|
||
reason: "可以基于源任务重新发起实拍剪辑。",
|
||
recoverable: true,
|
||
actionLabel: "恢复实拍剪辑",
|
||
actionKey: "recover-job"
|
||
};
|
||
}
|
||
if (lineType === "ai_video") {
|
||
if (!sourceJobId) {
|
||
return {
|
||
...base,
|
||
state: "manual",
|
||
label: "缺少源任务",
|
||
reason: "AI 视频缺少源任务,暂时无法自动恢复。",
|
||
recoverable: false,
|
||
actionLabel: "看源任务",
|
||
actionKey: "open-job-detail"
|
||
};
|
||
}
|
||
return {
|
||
...base,
|
||
state: "recoverable",
|
||
label: "可恢复",
|
||
reason: "可以基于源任务和当前 brief 重新发起 AI 视频。",
|
||
recoverable: true,
|
||
actionLabel: "恢复 AI 视频",
|
||
actionKey: "recover-job"
|
||
};
|
||
}
|
||
if (sourceType === "content_source_sync") {
|
||
if (!sourceAccountUrl) {
|
||
return {
|
||
...base,
|
||
state: "manual",
|
||
label: "缺少主页",
|
||
reason: "内容源同步缺少主页地址,暂时无法自动恢复。",
|
||
recoverable: false,
|
||
actionLabel: "去导入主页",
|
||
actionKey: "open-import-homepage"
|
||
};
|
||
}
|
||
return {
|
||
...base,
|
||
state: "recoverable",
|
||
label: "可恢复",
|
||
reason: "可以基于同一主页重新触发内容源同步。",
|
||
recoverable: true,
|
||
actionLabel: "恢复同步",
|
||
actionKey: "recover-job"
|
||
};
|
||
}
|
||
if (sourceType === "text" || sourceType === "video_link") {
|
||
const sourceValue = sourceType === "text"
|
||
? String(job?.artifacts?.input_text || job?.transcript_text || "").trim()
|
||
: String(job?.source_url || job?.artifacts?.video_url || job?.artifacts?.source_url || "").trim();
|
||
if (!sourceValue) {
|
||
return {
|
||
...base,
|
||
state: "manual",
|
||
label: "缺少输入",
|
||
reason: sourceType === "text" ? "缺少原始文本,暂时无法自动恢复。" : "缺少原始视频链接,暂时无法自动恢复。",
|
||
recoverable: false,
|
||
actionLabel: "查看详情",
|
||
actionKey: "open-job-detail"
|
||
};
|
||
}
|
||
return {
|
||
...base,
|
||
state: "recoverable",
|
||
label: "可恢复",
|
||
reason: sourceType === "text"
|
||
? "可以直接用原始文本重新发起分析。"
|
||
: "可以直接用原始视频链接重新发起分析。",
|
||
recoverable: true,
|
||
actionLabel: sourceType === "text" ? "恢复分析" : "恢复分析",
|
||
actionKey: "recover-job"
|
||
};
|
||
}
|
||
return {
|
||
...base,
|
||
state: "manual",
|
||
label: "需人工处理",
|
||
reason: "当前链路没有可自动恢复的模板,建议交给管理员处理。",
|
||
recoverable: false,
|
||
actionLabel: "管理员处理",
|
||
actionKey: "goto-production"
|
||
};
|
||
}
|
||
|
||
function getJobRecoveryRequest(job) {
|
||
const recovery = getJobRecoverability(job);
|
||
if (!recovery.recoverable) {
|
||
throw new Error(recovery.reason || "当前任务暂不支持自动恢复");
|
||
}
|
||
const projectId = job?.project_id || appState.selectedProjectId || "";
|
||
const assistantId = job?.assistant_id || "";
|
||
const knowledgeBaseId = job?.knowledge_base_id || "";
|
||
const analysisModelProfileId = job?.analysis_model_profile_id || "";
|
||
const title = String(job?.title || "任务").trim();
|
||
if (job?.source_type === "text") {
|
||
return {
|
||
endpoint: "/v2/explore/text",
|
||
body: {
|
||
project_id: projectId,
|
||
knowledge_base_id: knowledgeBaseId,
|
||
assistant_id: assistantId,
|
||
analysis_model_profile_id: analysisModelProfileId,
|
||
title,
|
||
content: String(job?.artifacts?.input_text || job?.transcript_text || "").trim()
|
||
},
|
||
reason: "基于原始文本重新发起分析"
|
||
};
|
||
}
|
||
if (job?.source_type === "video_link") {
|
||
return {
|
||
endpoint: "/v2/explore/video-link",
|
||
body: {
|
||
project_id: projectId,
|
||
knowledge_base_id: knowledgeBaseId,
|
||
assistant_id: assistantId,
|
||
analysis_model_profile_id: analysisModelProfileId,
|
||
title,
|
||
language: job?.language || "auto",
|
||
video_url: String(job?.source_url || job?.artifacts?.video_url || job?.artifacts?.source_url || "").trim()
|
||
},
|
||
reason: "基于原始视频链接重新发起分析"
|
||
};
|
||
}
|
||
if (job?.source_type === "content_source_sync") {
|
||
const sourceUrl = String(job?.artifacts?.source_account_url || job?.source_url || "").trim();
|
||
return {
|
||
endpoint: "/v2/pipelines/content-source-sync",
|
||
body: {
|
||
project_id: projectId,
|
||
knowledge_base_id: knowledgeBaseId,
|
||
assistant_id: assistantId,
|
||
analysis_model_profile_id: analysisModelProfileId,
|
||
content_source_id: String(job?.content_source_id || ""),
|
||
platform: String(job?.artifacts?.platform || ""),
|
||
handle: String(job?.artifacts?.handle || ""),
|
||
source_url: sourceUrl,
|
||
title,
|
||
language: job?.language || "auto",
|
||
max_items: Number(job?.artifacts?.max_items || 5),
|
||
skip_existing: Boolean(job?.artifacts?.skip_existing !== false),
|
||
auto_trigger_analysis: Boolean(job?.artifacts?.auto_trigger_analysis !== false)
|
||
},
|
||
reason: "基于同一主页重新触发同步"
|
||
};
|
||
}
|
||
if (job?.line_type === "real_cut" || job?.source_type === "real_cut") {
|
||
const request = job?.artifacts?.cutvideo_request || {};
|
||
const sourceJobId = String(job?.artifacts?.source_job_id || job?.parent_job_id || "").trim();
|
||
return {
|
||
endpoint: "/v2/pipelines/real-cut",
|
||
body: {
|
||
project_id: projectId,
|
||
title,
|
||
source_job_id: sourceJobId,
|
||
base_config: String(request.base_config || ""),
|
||
objective: String(request.objective || ""),
|
||
target_duration_sec: Number(request.target_duration_sec || 60),
|
||
target_aspect_ratio: String(request.target_aspect_ratio || "9:16"),
|
||
ideal_segment_duration_sec: Number(request.ideal_segment_duration_sec || 8),
|
||
max_segment_duration_sec: Number(request.max_segment_duration_sec || 20),
|
||
transcript_backend: String(request.transcript_backend || "auto"),
|
||
transcript_device: String(request.transcript_device || ""),
|
||
review_enabled: Boolean(request.review_enabled),
|
||
dry_run: Boolean(request.dry_run)
|
||
},
|
||
reason: "基于源任务重新发起实拍剪辑"
|
||
};
|
||
}
|
||
if (job?.line_type === "ai_video" || job?.source_type === "ai_video") {
|
||
const sourceJobId = String(job?.artifacts?.source_job_id || job?.result?.source_job_id || job?.parent_job_id || "").trim();
|
||
return {
|
||
endpoint: "/v2/pipelines/ai-video",
|
||
body: {
|
||
project_id: projectId,
|
||
assistant_id: assistantId,
|
||
knowledge_base_id: knowledgeBaseId,
|
||
source_job_id: sourceJobId,
|
||
title,
|
||
brief: String(job?.artifacts?.brief || job?.result?.brief || job?.style_summary || job?.transcript_text || "").trim(),
|
||
style: String(job?.artifacts?.style || "realistic"),
|
||
shots: Number(job?.artifacts?.shots || 4),
|
||
duration: Number(job?.artifacts?.duration || 5),
|
||
image_provider: String(job?.artifacts?.image_provider || ""),
|
||
image_model: String(job?.artifacts?.image_model || ""),
|
||
video_provider: String(job?.artifacts?.video_provider || ""),
|
||
video_model: String(job?.artifacts?.video_model || ""),
|
||
aspect_ratio: String(job?.artifacts?.aspect_ratio || "9:16")
|
||
},
|
||
reason: "基于源任务重新发起 AI 视频"
|
||
};
|
||
}
|
||
throw new Error("当前任务暂不支持自动恢复");
|
||
}
|
||
|
||
async function recoverJobAction(jobId, options = {}) {
|
||
const sourceJob = safeArray(appState.dashboard?.recent_jobs).find((item) => item.id === jobId)
|
||
|| (appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null);
|
||
const job = options.job || sourceJob || await storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(jobId)}`).catch(() => null);
|
||
if (!job) {
|
||
throw new Error("没有找到这条任务");
|
||
}
|
||
const recovery = getJobRecoverability(job);
|
||
if (!recovery.recoverable) {
|
||
throw new Error(recovery.reason || "当前任务暂不支持恢复");
|
||
}
|
||
try {
|
||
const retried = await storyforgeFetch(`/v2/explore/jobs/${encodeURIComponent(job.id)}/retry`, {
|
||
method: "POST"
|
||
});
|
||
const retriedJob = retried?.job || retried;
|
||
recordRecoveryEvent({
|
||
job_id: job.id,
|
||
job_title: job.title,
|
||
job_line_type: job.line_type,
|
||
job_source_type: job.source_type,
|
||
job_status: job.status,
|
||
action_key: "recover-job",
|
||
mode: options.mode || "single",
|
||
summary: `${job.title || job.id} 已重新入队`,
|
||
reason: "通过任务重试接口恢复",
|
||
target_job_id: retriedJob?.id || job.id,
|
||
target_job_title: retriedJob?.title || job.title || "",
|
||
target_status: retriedJob?.status || "",
|
||
result_label: "已重新入队",
|
||
result_reason: recovery.reason,
|
||
user_feedback: String(options.user_feedback || "")
|
||
});
|
||
return {
|
||
created: retriedJob,
|
||
source: job,
|
||
request: {
|
||
endpoint: `/v2/explore/jobs/${encodeURIComponent(job.id)}/retry`,
|
||
body: {},
|
||
reason: "通过任务重试接口恢复"
|
||
}
|
||
};
|
||
} catch (error) {
|
||
if (!isMissingBackendCapability(error)) {
|
||
throw error;
|
||
}
|
||
if (job?.source_type === "upload_video") {
|
||
throw new Error("当前实例没有开放上传任务重试接口,暂时无法自动恢复已上传素材。");
|
||
}
|
||
}
|
||
const request = getJobRecoveryRequest(job);
|
||
const payload = await storyforgeFetch(request.endpoint, {
|
||
method: "POST",
|
||
body: request.body
|
||
});
|
||
const created = payload?.job || payload?.result?.job || payload;
|
||
const createdJobId = created?.id || created?.job_id || "";
|
||
recordRecoveryEvent({
|
||
job_id: job.id,
|
||
job_title: job.title,
|
||
job_line_type: job.line_type,
|
||
job_source_type: job.source_type,
|
||
job_status: job.status,
|
||
action_key: "recover-job",
|
||
mode: options.mode || "single",
|
||
summary: `${job.title || job.id} 已重新发起`,
|
||
reason: request.reason,
|
||
target_job_id: createdJobId,
|
||
target_job_title: created?.title || created?.job?.title || "",
|
||
target_status: created?.status || created?.job?.status || "",
|
||
result_label: "已创建恢复任务",
|
||
result_reason: request.reason,
|
||
user_feedback: String(options.user_feedback || "")
|
||
});
|
||
return {
|
||
created,
|
||
source: job,
|
||
request
|
||
};
|
||
}
|
||
|
||
function getRecoverableFailedJobs() {
|
||
return safeArray(appState.dashboard?.recent_jobs)
|
||
.filter((job) => String(job?.status || "").toLowerCase() === "failed")
|
||
.map((job) => ({ job, recovery: getJobRecoverability(job) }))
|
||
.sort((left, right) => compareDateDesc(left.job.updated_at || left.job.created_at, right.job.updated_at || right.job.created_at));
|
||
}
|
||
|
||
function renderRecoveryHistoryPanel() {
|
||
const records = getRecoveryRecords();
|
||
const recentRecords = records.slice(0, 6);
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>最近恢复记录</h3>
|
||
<div class="panel-subtitle">本地记录最近一次恢复动作,方便量产阶段回看恢复是否真的跑通。</div>
|
||
</div>
|
||
<span class="tag blue">${escapeHtml(formatNumber(records.length))} 条</span>
|
||
</div>
|
||
<div class="list">
|
||
${recentRecords.map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.job_title || "恢复记录")}</h4>
|
||
<p>${escapeHtml(item.summary || item.reason || "恢复动作已完成。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(item.mode || "single")}</span>
|
||
<span class="tag ${item.target_job_id ? "green" : "orange"}">${escapeHtml(item.target_job_id ? "已创建恢复任务" : "待确认结果")}</span>
|
||
${item.job_line_type ? `<span class="tag">${escapeHtml(item.job_line_type)}</span>` : ""}
|
||
${item.created_at ? `<span class="tag">${escapeHtml(formatDateTime(item.created_at))}</span>` : ""}
|
||
${item.target_job_id ? `<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(item.target_job_id)}">看恢复任务</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>还没有恢复记录</h4><p>你执行过的恢复动作会出现在这里。</p></div>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderQuotaBlockingNotice() {
|
||
const quota = appState.tenantQuota || {};
|
||
const usage = appState.tenantUsage || quota?.usage || {};
|
||
const labels = [
|
||
{ key: "analysis", label: "分析" },
|
||
{ key: "copy", label: "文案" },
|
||
{ key: "ai_video", label: "AI 视频" },
|
||
{ key: "real_cut", label: "实拍剪辑" },
|
||
{ key: "recorder", label: "录制" }
|
||
];
|
||
const blocked = labels
|
||
.map((item) => {
|
||
const guard = getQuotaSummaryForCategory(item.key);
|
||
return { ...item, guard };
|
||
})
|
||
.filter((item) => item.guard.blocked);
|
||
const storageBlocked = Boolean(quota?.storage_over_limit);
|
||
if (!blocked.length && !storageBlocked) return "";
|
||
return `
|
||
<div class="panel pad quota-notice">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>租户拦截提示</h3>
|
||
<div class="panel-subtitle">当前租户已经触发了硬拦截,前端会先提示原因,再引导你补额度或清理存储。</div>
|
||
</div>
|
||
<span class="tag red">已拦截</span>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>${escapeHtml(storageBlocked ? "存储额度已满" : "动作额度已满")}</h4>
|
||
<p>${escapeHtml(storageBlocked
|
||
? "当前租户存储已经达到上限,建议先清理旧产物或提升存储配额。"
|
||
: blocked.map((item) => item.guard.reason).join(";"))}</p>
|
||
<div class="task-meta">
|
||
${blocked.map((item) => `<span class="tag red">${escapeHtml(item.label)}已满</span>`).join("")}
|
||
${storageBlocked ? `<span class="tag red">存储超限</span>` : ""}
|
||
<span class="tag clickable-tag" data-action="open-tenant-quota">调整额度</span>
|
||
</div>
|
||
</div>
|
||
<div class="mini-grid" style="margin-top:14px;">
|
||
<div class="mini-card">
|
||
<small>预算</small>
|
||
<strong>${escapeHtml(formatNumber((quota?.monthly_budget_cents || 0) / 100))} 元</strong>
|
||
<span>已用 ${escapeHtml(formatNumber((usage?.total_cost_cents || 0) / 100))} 元</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>存储</small>
|
||
<strong>${escapeHtml(formatBytes(usage?.storage_bytes || 0))}</strong>
|
||
<span>${escapeHtml(quota?.storage_limit_bytes ? `上限 ${formatBytes(quota.storage_limit_bytes)}` : "未设置上限")}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>分析</small>
|
||
<strong>${escapeHtml(formatNumber(usage?.categories?.analysis?.quantity || 0))}</strong>
|
||
<span>${escapeHtml(`上限 ${formatNumber(quota?.analysis_quota || 0)}`)}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<small>AI 视频</small>
|
||
<strong>${escapeHtml(formatNumber(usage?.categories?.ai_video?.quantity || 0))}</strong>
|
||
<span>${escapeHtml(`上限 ${formatNumber(quota?.ai_video_quota || 0)}`)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderLastJobDetailCard() {
|
||
const detail = appState.lastJobDetail;
|
||
if (!detail?.job) return "";
|
||
const previewLinks = getJobPreviewLinks(detail.job);
|
||
const recovery = getJobRecoverability(detail.job);
|
||
return `
|
||
<div class="panel pad">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h3>最近任务详情</h3>
|
||
<div class="panel-subtitle">${escapeHtml(formatDateTime(detail.job.created_at))}</div>
|
||
</div>
|
||
<div class="task-meta">
|
||
<span class="tag ${statusTone(detail.job.status)}">${escapeHtml(detail.job.status || "-")}</span>
|
||
<span class="tag ${recovery.recoverable ? "green" : recovery.state === "blocked" ? "red" : "orange"}">${escapeHtml(recovery.label)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="task-item">
|
||
<h4>${escapeHtml(detail.job.title || detail.job.id)}</h4>
|
||
<p>${escapeHtml(brief(detail.job.style_summary || detail.job.transcript_text || detail.job.error || "暂无摘要", 120))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(detail.job.line_type || "-")}</span>
|
||
${detail.job.status === "failed" ? `<span class="tag ${recovery.recoverable ? "green" : "orange"}">${escapeHtml(recovery.reason)}</span>` : ""}
|
||
${detail.job.status === "completed" ? actionTag("写复盘", "open-review-from-job", `data-job-id="${escapeHtml(detail.job.id)}"`) : ""}
|
||
${detail.job.status === "failed" ? actionTag(
|
||
recovery.actionLabel,
|
||
recovery.recoverable ? "recover-job" : recovery.actionKey,
|
||
`data-job-id="${escapeHtml(detail.job.id)}"`,
|
||
{ disabledReason: recovery.recoverable ? "" : recovery.reason, title: recovery.reason }
|
||
) : ""}
|
||
${canDeriveAiVideo(detail.job) ? renderPipelineJobTag("aiVideo", detail.job, "做 AI 视频") : ""}
|
||
${canDeriveRealCut(detail.job) ? renderPipelineJobTag("realCut", detail.job, "做实拍剪辑") : ""}
|
||
${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(detail.job.id)}"`)}
|
||
</div>
|
||
</div>
|
||
${detail.job.status === "failed" ? `
|
||
<div class="task-item compact">
|
||
<h4>恢复判断</h4>
|
||
<p>${escapeHtml(recovery.reason)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${recovery.recoverable ? "green" : recovery.state === "blocked" ? "red" : "orange"}">${escapeHtml(recovery.actionLabel)}</span>
|
||
${recovery.sourceJobId ? `<span class="tag">源任务 ${escapeHtml(brief(recovery.sourceJobId, 12))}</span>` : ""}
|
||
${recovery.quotaGuard?.blocked ? `<span class="tag red">额度拦截</span>` : ""}
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${previewLinks.length ? `
|
||
<div class="list">
|
||
${previewLinks.slice(0, 3).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.label.replace(/^result\./, "").replace(/^artifacts\./, ""))}</h4>
|
||
<p>${escapeHtml(item.url)}</p>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function requireSelectedProject() {
|
||
const project = getSelectedProject();
|
||
if (!project) throw new Error("请先创建项目");
|
||
return project;
|
||
}
|
||
|
||
function requireSelectedAssistant() {
|
||
const assistant = getSelectedAssistant();
|
||
if (!assistant) throw new Error("请先创建 Agent");
|
||
return assistant;
|
||
}
|
||
|
||
function requireSelectedAccountRow() {
|
||
const account = getSelectedAccount();
|
||
if (!account) throw new Error("请先在“找对标”里选中一个账号");
|
||
return account;
|
||
}
|
||
|
||
function openImportHomepageAction() {
|
||
const project = requireSelectedProject();
|
||
const kb = getProjectKnowledgeBases(project.id)[0];
|
||
const assistants = getAssistantOptions(project.id);
|
||
openActionModal({
|
||
title: "导入主页并同步",
|
||
description: "适合抖音 / 小红书 / B站 / 快手 / 视频号主页。先建内容源,再触发同步与分析。",
|
||
submitLabel: "开始同步",
|
||
fields: [
|
||
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
|
||
{ name: "platform", label: "平台", type: "select", value: "douyin", options: getPlatformOptions() },
|
||
{ name: "title", label: "标题", placeholder: "例如:创业口播对标账号" },
|
||
{ name: "handle", label: "账号名 / handle", placeholder: "可选" },
|
||
{ name: "sourceUrl", label: "主页链接", type: "url", placeholder: "https://..." },
|
||
{ name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
|
||
{ name: "maxItems", label: "最多同步作品数", type: "number", value: 5, min: 1, max: 20 }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.sourceUrl?.trim()) throw new Error("请填写主页链接");
|
||
const projectId = values.projectId || project.id;
|
||
const platform = normalizePlatformValue(values.platform, "douyin");
|
||
const source = await storyforgeFetch("/v2/content-sources", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: projectId,
|
||
source_kind: "creator_account",
|
||
platform,
|
||
handle: values.handle || "",
|
||
source_url: values.sourceUrl.trim(),
|
||
title: values.title || values.handle || "主页对标",
|
||
metadata: {}
|
||
}
|
||
});
|
||
const job = await storyforgeFetch("/v2/pipelines/content-source-sync", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: projectId,
|
||
knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || kb?.id || "",
|
||
assistant_id: values.assistantId || "",
|
||
content_source_id: source.id,
|
||
platform,
|
||
handle: values.handle || "",
|
||
source_url: values.sourceUrl.trim(),
|
||
title: values.title || values.handle || "主页对标",
|
||
max_items: Number(values.maxItems || 5),
|
||
skip_existing: true,
|
||
auto_trigger_analysis: true
|
||
}
|
||
});
|
||
rememberAction("主页同步已启动", `已把主页加入项目,并创建同步任务 ${job.title || job.id}。`, "blue", job);
|
||
await bootstrap();
|
||
if (job?.id) {
|
||
openJobDetailAction(job.id);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function openImportSelectedAccountAction() {
|
||
const account = requireSelectedAccountRow();
|
||
const platform = getAccountPlatform(account);
|
||
const project = requireSelectedProject();
|
||
const assistants = getAssistantOptions(project.id);
|
||
const currentSources = getCurrentProjectSourcesForAccount(account, project.id);
|
||
const currentSource = currentSources[0];
|
||
const kb = getProjectKnowledgeBases(project.id)[0];
|
||
openActionModal({
|
||
title: currentSource ? "继续同步当前对标" : "导入当前对标",
|
||
description: currentSource
|
||
? "当前项目里已经有这个对标账号,继续触发同步并可切换绑定 Agent。"
|
||
: "把当前选中的对标账号加入项目,并绑定 Agent 进入持续同步。",
|
||
submitLabel: currentSource ? "继续同步" : "导入并同步",
|
||
fields: [
|
||
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
|
||
{ name: "platform", label: "平台", type: "select", value: normalizePlatformValue(currentSource?.platform || platform), options: getPlatformOptions() },
|
||
{ name: "title", label: "内容源标题", value: currentSource?.title || `${getAccountName(account)} 对标主页` },
|
||
{ name: "handle", label: "账号标识", value: currentSource?.handle || getAccountHandle(account) || "" },
|
||
{ name: "sourceUrl", label: "主页链接", type: "url", value: currentSource?.source_url || getAccountProfileUrl(account) || "", placeholder: "https://..." },
|
||
{ name: "assistantId", label: "绑定 Agent", type: "select", value: getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
|
||
{ name: "maxItems", label: "最多同步作品数", type: "number", value: Number(currentSource?.metadata?.max_items || 6), min: 1, max: 20 },
|
||
{ name: "skipExisting", label: "跳过已存在作品", type: "checkbox", value: true },
|
||
{ name: "autoAnalyze", label: "同步后自动分析", type: "checkbox", value: true }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.sourceUrl?.trim()) throw new Error("请先填写主页链接");
|
||
const projectId = values.projectId || project.id;
|
||
const platform = normalizePlatformValue(values.platform, "douyin");
|
||
const source = currentSource && currentSource.project_id === projectId
|
||
? currentSource
|
||
: await storyforgeFetch("/v2/content-sources", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: projectId,
|
||
source_kind: "creator_account",
|
||
platform,
|
||
handle: values.handle || "",
|
||
source_url: values.sourceUrl.trim(),
|
||
title: values.title || values.handle || getAccountName(account) || "对标主页",
|
||
metadata: {
|
||
imported_from_account_id: account.id,
|
||
imported_from_workspace: "discovery"
|
||
}
|
||
}
|
||
});
|
||
const job = await storyforgeFetch("/v2/pipelines/content-source-sync", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: projectId,
|
||
knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || kb?.id || "",
|
||
assistant_id: values.assistantId || "",
|
||
content_source_id: source.id,
|
||
platform,
|
||
handle: values.handle || getAccountHandle(account) || "",
|
||
source_url: values.sourceUrl.trim(),
|
||
title: values.title || getAccountName(account) || values.handle || "对标主页",
|
||
max_items: Number(values.maxItems || 6),
|
||
skip_existing: Boolean(values.skipExisting),
|
||
auto_trigger_analysis: Boolean(values.autoAnalyze)
|
||
}
|
||
});
|
||
rememberAction("对标已接入项目", `已把「${getAccountName(account) || "当前对标"}」接入项目,并创建同步任务 ${job.title || job.id}。`, "green", { source, job });
|
||
await bootstrap();
|
||
if (job?.id) {
|
||
openJobDetailAction(job.id);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function openTrackSelectedAccountAction() {
|
||
const account = requireSelectedAccountRow();
|
||
const platform = getAccountPlatform(account);
|
||
const trackingAccountsPath = getWorkbenchRoute(platform, "trackingAccounts");
|
||
const project = requireSelectedProject();
|
||
const assistants = getAssistantOptions(project.id);
|
||
const trackedItem = safeArray(appState.trackingAccounts).find((item) => item.tracked_account_id === account.id);
|
||
openActionModal({
|
||
title: trackedItem ? "更新跟踪账号" : "加入跟踪",
|
||
description: trackedItem
|
||
? "这个账号已经在跟踪中,可以切换负责 Agent 或补充备注。"
|
||
: "把当前对标账号加入每日跟踪,后续自动生成更新日报。",
|
||
submitLabel: trackedItem ? "保存跟踪" : "开始跟踪",
|
||
fields: [
|
||
{ name: "accountName", label: "账号", type: "html", html: `<div class="sheet-html"><strong>${escapeHtml(getAccountName(account) || "未命名账号")}</strong><p>${escapeHtml(getAccountProfileUrl(account) || account.signature || "")}</p></div>` },
|
||
{ name: "assistantId", label: "负责 Agent", type: "select", value: trackedItem?.assistant_id || getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] },
|
||
{ name: "note", label: "跟踪备注", value: trackedItem?.note || "", placeholder: "例如:重点观察开头结构、成交句式和更新频率" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
await storyforgeFetch(trackingAccountsPath, {
|
||
method: "POST",
|
||
body: {
|
||
tracked_account_id: account.id,
|
||
assistant_id: values.assistantId || "",
|
||
note: values.note || ""
|
||
}
|
||
});
|
||
rememberAction(trackedItem ? "跟踪已更新" : "已加入跟踪", `账号「${getAccountName(account) || "当前对标"}」现在会进入更新日报。`, "green");
|
||
await bootstrap();
|
||
focusTrackingWorkspace();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openImportVideoLinkAction() {
|
||
const project = requireSelectedProject();
|
||
const assistants = getAssistantOptions(project.id);
|
||
openActionModal({
|
||
title: "导入作品链接",
|
||
description: "直接把单条视频链接送进分析链。",
|
||
submitLabel: "开始分析",
|
||
fields: [
|
||
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
|
||
{ name: "title", label: "标题", placeholder: "可选,不填则使用默认标题" },
|
||
{ name: "videoUrl", label: "作品链接", type: "url", placeholder: "https://..." },
|
||
{ name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
|
||
{ name: "language", label: "语言", type: "select", value: "auto", options: [{ value: "auto", label: "自动" }, { value: "zh-CN", label: "中文" }] }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.videoUrl?.trim()) throw new Error("请填写作品链接");
|
||
const projectId = values.projectId || project.id;
|
||
const job = await storyforgeFetch("/v2/explore/video-link", {
|
||
method: "POST",
|
||
body: {
|
||
video_url: values.videoUrl.trim(),
|
||
title: values.title || "",
|
||
project_id: projectId,
|
||
knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || "",
|
||
assistant_id: values.assistantId || "",
|
||
language: values.language || "auto"
|
||
}
|
||
});
|
||
rememberAction("作品分析已启动", `已创建分析任务 ${job.title || job.id}。`, "blue", job);
|
||
await bootstrap();
|
||
if (job?.id) {
|
||
openJobDetailAction(job.id);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function openImportTextAction() {
|
||
const project = requireSelectedProject();
|
||
const assistants = getAssistantOptions(project.id);
|
||
openActionModal({
|
||
title: "导入文本素材",
|
||
description: "把口播稿、拆解稿或灵感文本直接送进知识与分析链。",
|
||
submitLabel: "开始分析",
|
||
fields: [
|
||
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
|
||
{ name: "title", label: "标题", placeholder: "例如:创业口播拆解" },
|
||
{ name: "content", label: "正文", type: "textarea", rows: 8, placeholder: "粘贴需要分析的文本" },
|
||
{ name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.title?.trim()) throw new Error("请填写标题");
|
||
if (!values.content?.trim()) throw new Error("请填写正文");
|
||
const projectId = values.projectId || project.id;
|
||
const job = await storyforgeFetch("/v2/explore/text", {
|
||
method: "POST",
|
||
body: {
|
||
title: values.title.trim(),
|
||
content: values.content.trim(),
|
||
project_id: projectId,
|
||
knowledge_base_id: getProjectKnowledgeBases(projectId)[0]?.id || "",
|
||
assistant_id: values.assistantId || ""
|
||
}
|
||
});
|
||
rememberAction("文本分析已启动", `已创建文本分析任务 ${job.title || job.id}。`, "blue", job);
|
||
await bootstrap();
|
||
if (job?.id) {
|
||
openJobDetailAction(job.id);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function openUploadVideoAction() {
|
||
const project = requireSelectedProject();
|
||
const assistants = getAssistantOptions(project.id);
|
||
openActionModal({
|
||
title: "上传本地视频",
|
||
description: "上传本地素材,直接进入分析链。",
|
||
submitLabel: "上传并分析",
|
||
fields: [
|
||
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
|
||
{ name: "title", label: "标题", placeholder: "可选,不填则用文件名" },
|
||
{ name: "assistantId", label: "绑定 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
|
||
{ name: "file", label: "本地视频", type: "file", accept: ".mp4,.mov,.m4v,.avi,.mkv,.webm" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.file) throw new Error("请先选择本地视频");
|
||
const projectId = values.projectId || project.id;
|
||
const form = new FormData();
|
||
form.append("file", values.file);
|
||
form.append("title", values.title || "");
|
||
form.append("project_id", projectId);
|
||
form.append("knowledge_base_id", getProjectKnowledgeBases(projectId)[0]?.id || "");
|
||
form.append("assistant_id", values.assistantId || "");
|
||
const job = await storyforgeFetch("/v2/explore/upload-video", {
|
||
method: "POST",
|
||
body: form
|
||
});
|
||
rememberAction("上传分析已启动", `已上传素材并创建任务 ${job.title || job.id}。`, "blue", job);
|
||
await bootstrap();
|
||
if (job?.id) {
|
||
openJobDetailAction(job.id);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function openOneLinerProfileAction() {
|
||
const project = requireSelectedProject();
|
||
const assistants = getAssistantOptions(project.id);
|
||
const profile = appState.onelinerProfile || {};
|
||
openActionModal({
|
||
title: "配置 OneLiner",
|
||
description: "绑定总控主 Agent 的默认平台、长期目标和默认执行 Agent。",
|
||
submitLabel: "保存配置",
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(profile, "你还没有发布过 OneLiner 主配置,当前会沿用默认初始化版本。") },
|
||
{ name: "assistantId", label: "默认执行 Agent", type: "select", value: profile.assistant_id || getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] },
|
||
{ name: "displayName", label: "显示名", value: profile.display_name || "OneLiner", placeholder: "例如:增长总控 OneLiner" },
|
||
{ name: "defaultPlatform", label: "默认平台", type: "select", value: normalizePlatformValue(profile.default_platform || getPreferredPlatform(), "douyin"), options: getPlatformOptions() },
|
||
{ name: "longTermGoal", label: "长期目标", type: "textarea", rows: 4, value: profile.long_term_goal || "", placeholder: "例如:围绕创业 IP 做跨平台增长与成交转化" },
|
||
{ name: "notes", label: "补充说明", type: "textarea", rows: 4, value: profile.notes || "", placeholder: "例如:前端没产品化的需求先由 OneLiner 承接,不允许直接改核心代码" },
|
||
{ name: "reason", label: "变更原因", type: "textarea", rows: 3, value: "", placeholder: "例如:本轮主 Agent 默认改为围绕抖音增长执行" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch("/v2/oneliner/profile", {
|
||
method: "PUT",
|
||
body: {
|
||
project_id: project.id,
|
||
assistant_id: values.assistantId || "",
|
||
display_name: values.displayName || "OneLiner",
|
||
default_platform: values.defaultPlatform || "douyin",
|
||
long_term_goal: values.longTermGoal || "",
|
||
notes: values.notes || "",
|
||
reason: values.reason || "",
|
||
config: {
|
||
chat_only_for_unreleased_ui: true,
|
||
commercial_ready: true,
|
||
tenant_isolation_required: true
|
||
}
|
||
}
|
||
});
|
||
appState.onelinerProfile = saved;
|
||
await loadAgentControlSurfaces(project.id);
|
||
rememberAction("OneLiner 已保存", `已更新 OneLiner「${saved.display_name || "OneLiner"}」配置,当前版本 ${saved.current_version?.version_no || 1}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openOneLinerProfileHistoryAction() {
|
||
const project = requireSelectedProject();
|
||
const history = await loadPolicyVersions(`/v2/oneliner/profile/versions?project_id=${encodeURIComponent(project.id)}`);
|
||
const auditsPayload = await storyforgeFetch(`/v2/oneliner/profile/audits?project_id=${encodeURIComponent(project.id)}`).catch(() => ({ items: [] }));
|
||
const audits = safeArray(auditsPayload?.items || auditsPayload);
|
||
const selectedVersionId = history.items[0]?.id || "";
|
||
openActionModal({
|
||
title: "OneLiner 主配置历史",
|
||
description: "回看主 Agent 核心配置的历史版本、变更原因和回滚记录。",
|
||
submitLabel: "回滚到这个版本",
|
||
hideSubmit: !selectedVersionId,
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "当前配置状态",
|
||
html: renderPolicyVersionSummary(appState.onelinerProfile || {}, "当前项目的 OneLiner 还没有历史版本。")
|
||
},
|
||
...(
|
||
selectedVersionId
|
||
? [
|
||
{ name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: buildPolicyVersionOptions(history) },
|
||
{ name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:回到上一个稳定版本,继续沿用既有执行节奏" }
|
||
]
|
||
: []
|
||
),
|
||
{
|
||
type: "html",
|
||
label: "历史版本",
|
||
html: `<div class="list">${renderPolicyVersionsHtml(history.items, "OneLiner 主配置还没有历史版本。")}</div>`
|
||
},
|
||
{
|
||
type: "html",
|
||
label: "最近审计",
|
||
html: `<div class="list">${renderPolicyAuditFeed(audits, "还没有 OneLiner 主配置变更记录。")}</div>`
|
||
}
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch("/v2/oneliner/profile/rollback", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: project.id,
|
||
version_id: values.versionId,
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.onelinerProfile = saved;
|
||
await loadAgentControlSurfaces(project.id);
|
||
rememberAction("OneLiner 已回滚", `已回滚到版本 ${saved.current_version?.version_no || "指定版本"}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function parsePolicyJsonField(rawValue, label = "策略 JSON") {
|
||
const text = String(rawValue || "").trim();
|
||
if (!text) return {};
|
||
try {
|
||
const parsed = JSON.parse(text);
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
throw new Error(`${label} 必须是 JSON 对象`);
|
||
}
|
||
return parsed;
|
||
} catch (error) {
|
||
throw new Error(`${label} 格式不正确:${error.message}`);
|
||
}
|
||
}
|
||
|
||
function renderPolicyVersionSummary(bundle, emptyText) {
|
||
if (!bundle?.current_version) {
|
||
return `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>还没有已发布版本</h4>
|
||
<p>${escapeHtml(emptyText)}</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(bundle.current_version.title || bundle.scope?.title || "当前版本")}</h4>
|
||
<p>${escapeHtml(bundle.current_version.summary || "当前版本还没有补摘要。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">版本 ${escapeHtml(formatNumber(bundle.current_version.version_no || 0))}</span>
|
||
<span class="tag">历史 ${escapeHtml(formatNumber(bundle.versions?.count || 0))}</span>
|
||
${bundle.effectivity?.effect_mode ? `<span class="tag">${escapeHtml(bundle.effectivity.effect_mode)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function getAdminGovernanceDirectoryItems() {
|
||
return safeArray(appState.adminGovernanceDirectory);
|
||
}
|
||
|
||
function findAdminGovernanceDirectoryItem(targetUserId) {
|
||
return getAdminGovernanceDirectoryItems().find((item) => item.id === targetUserId) || null;
|
||
}
|
||
|
||
function getAdminOverrideTargetState() {
|
||
const directoryItems = getAdminGovernanceDirectoryItems();
|
||
const existing = appState.adminOverrideTarget || {};
|
||
const targetUserId = String(existing.targetUserId || existing.target_user_id || directoryItems[0]?.id || "");
|
||
const targetUser = findAdminGovernanceDirectoryItem(targetUserId) || directoryItems[0] || null;
|
||
const targetProjects = safeArray(targetUser?.projects);
|
||
const targetProjectId = String(existing.targetProjectId || existing.target_project_id || targetProjects[0]?.id || "");
|
||
return {
|
||
targetUserId,
|
||
targetProjectId,
|
||
platform: normalizePlatformValue(existing.platform || getPreferredPlatform(), "douyin")
|
||
};
|
||
}
|
||
|
||
function formatAdminGovernanceTargetLabel(target) {
|
||
const directoryItem = findAdminGovernanceDirectoryItem(target?.targetUserId || target?.target_user_id || "");
|
||
const project = safeArray(directoryItem?.projects).find((item) => item.id === (target?.targetProjectId || target?.target_project_id || ""));
|
||
const userLabel = directoryItem ? `${directoryItem.display_name || directoryItem.username || directoryItem.id}${directoryItem.role ? ` · ${directoryItem.role}` : ""}` : "未选择目标";
|
||
const projectLabel = project ? project.name || project.id : "默认用户全局";
|
||
return `${userLabel} / ${projectLabel}`;
|
||
}
|
||
|
||
function getAdminGovernanceDirectoryUserOptions() {
|
||
return getAdminGovernanceDirectoryItems().map((item) => ({
|
||
value: item.id,
|
||
label: `${item.display_name || item.username || item.id}${item.project_count ? ` · ${formatNumber(item.project_count)} 项目` : ""}`
|
||
}));
|
||
}
|
||
|
||
function getAdminGovernanceDirectoryProjectOptions(targetUserId) {
|
||
const directoryItem = findAdminGovernanceDirectoryItem(targetUserId);
|
||
return safeArray(directoryItem?.projects).map((item) => ({
|
||
value: item.id,
|
||
label: item.name || item.id
|
||
}));
|
||
}
|
||
|
||
function renderPolicyVersionsHtml(items, emptyText = "暂无历史版本。") {
|
||
const versions = safeArray(items);
|
||
if (!versions.length) {
|
||
return `<div class="task-item compact"><h4>还没有历史版本</h4><p>${escapeHtml(emptyText)}</p></div>`;
|
||
}
|
||
return versions.slice(0, 8).map((version) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(version.title || `版本 ${formatNumber(version.version_no || 0)}`)}</h4>
|
||
<p>${escapeHtml(version.summary || "没有补充摘要。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">版本 ${escapeHtml(formatNumber(version.version_no || 0))}</span>
|
||
${version.created_at ? `<span class="tag">${escapeHtml(formatDateTime(version.created_at))}</span>` : ""}
|
||
${version.rollback_from_version_id ? `<span class="tag orange">回滚生成</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
async function loadPolicyVersions(url) {
|
||
const payload = await storyforgeFetch(url).catch(() => ({ items: [] }));
|
||
const items = safeArray(payload?.items || payload);
|
||
return { items, count: Number(payload?.count || items.length) };
|
||
}
|
||
|
||
function renderPolicyAuditFeed(items, emptyText = "还没有策略变更记录。") {
|
||
const records = safeArray(items);
|
||
if (!records.length) {
|
||
return `<div class="task-item compact"><h4>还没有治理动作</h4><p>${escapeHtml(emptyText)}</p></div>`;
|
||
}
|
||
return records.slice(0, 10).map((item) => {
|
||
const scopeKind = item.scope_kind || item.scope?.scope_kind || "";
|
||
const platform = item.platform || item.scope?.platform || "";
|
||
const version = item.version || {};
|
||
const details = item.details || {};
|
||
const rollbackId = version.rollback_from_version_id || details.rollback_to_version_id || "";
|
||
return `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.summary || version.title || item.action_key || "策略变更")}</h4>
|
||
<p>${escapeHtml(version.summary || version.reason || "当前记录没有补充摘要。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(policyScopeTagLabel(scopeKind, platform))}</span>
|
||
${version.version_no ? `<span class="tag">版本 ${escapeHtml(formatNumber(version.version_no || 0))}</span>` : ""}
|
||
${item.action_key ? `<span class="tag">${escapeHtml(item.action_key)}</span>` : ""}
|
||
${platform ? `<span class="tag">${escapeHtml(platformLabel(platform))}</span>` : ""}
|
||
${rollbackId ? `<span class="tag orange">回滚动作</span>` : ""}
|
||
${item.created_at ? `<span class="tag">${escapeHtml(formatDateTime(item.created_at))}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
function buildPolicyVersionOptions(history) {
|
||
return safeArray(history?.items).map((item) => ({
|
||
value: item.id,
|
||
label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}`
|
||
}));
|
||
}
|
||
|
||
function ensureAdminGovernanceAccess() {
|
||
if (isSuperAdmin()) return true;
|
||
rememberAction("需要管理员权限", "当前动作仅超级管理员可用,请切换到管理员账号后再继续。", "orange");
|
||
renderAll();
|
||
return false;
|
||
}
|
||
|
||
function ensureAdminOverrideTargetReady(target) {
|
||
if (!ensureAdminGovernanceAccess()) return false;
|
||
if (target?.targetUserId) return true;
|
||
rememberAction("还没有可治理目标", "当前治理目录里还没有可选用户,暂时无法执行管理员覆盖动作。", "orange");
|
||
renderAll();
|
||
return false;
|
||
}
|
||
|
||
function openUserGlobalPolicyAction() {
|
||
const project = requireSelectedProject();
|
||
const bundle = appState.userGlobalPolicy || {};
|
||
const current = bundle.current_version || {};
|
||
openActionModal({
|
||
title: "编辑我的全局策略",
|
||
description: "这层策略只影响你自己,会优先被主 Agent 读取,再决定是否下发到各个平台 Agent。",
|
||
submitLabel: "保存全局策略",
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, "你还没有发布自己的全局策略,当前会沿用系统默认和平台默认。") },
|
||
{ name: "title", label: "策略标题", value: current.title || bundle.scope?.title || "用户全局策略", placeholder: "例如:创业内容增长策略" },
|
||
{ name: "summary", label: "摘要", type: "textarea", rows: 3, value: current.summary || "", placeholder: "写清楚这层策略主要在约束什么、优化什么" },
|
||
{ name: "policyJson", label: "策略 JSON", type: "textarea", rows: 8, value: JSON.stringify(current.policy || {}, null, 2), placeholder: "{\"tone\":{\"style\":\"analytical\"}}" },
|
||
{ name: "reason", label: "变更原因", type: "textarea", rows: 3, value: "", placeholder: "例如:用户要求首页动作更聚焦,默认走分析型语气" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch("/v2/oneliner/governance/user/global", {
|
||
method: "PUT",
|
||
body: {
|
||
project_id: project.id,
|
||
title: values.title || "用户全局策略",
|
||
summary: values.summary || "",
|
||
policy: parsePolicyJsonField(values.policyJson, "全局策略 JSON"),
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.userGlobalPolicy = saved;
|
||
await loadAgentControlSurfaces(project.id);
|
||
rememberAction("我的全局策略已保存", `已发布版本 ${saved.current_version?.version_no || 1}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openUserPlatformPolicyAction(platform) {
|
||
const normalizedPlatform = normalizePlatformValue(platform || getPreferredPlatform(), "douyin");
|
||
const project = requireSelectedProject();
|
||
const bundle = appState.userCurrentPlatformPolicy || {};
|
||
const current = bundle.current_version || {};
|
||
openActionModal({
|
||
title: `编辑 ${platformLabel(normalizedPlatform)} 平台策略`,
|
||
description: "这层策略只作用于你当前项目下的单个平台,会覆盖你的全局策略,但不会影响其他平台。",
|
||
submitLabel: "保存平台策略",
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, "你还没有发布单平台策略,当前会沿用全局策略和系统默认。") },
|
||
{ name: "title", label: "策略标题", value: current.title || `${platformLabel(normalizedPlatform)} 用户平台策略`, placeholder: "例如:抖音对标拆解策略" },
|
||
{ name: "summary", label: "摘要", type: "textarea", rows: 3, value: current.summary || "", placeholder: "写清楚这个平台的特殊规则和工作方式" },
|
||
{ name: "policyJson", label: "策略 JSON", type: "textarea", rows: 8, value: JSON.stringify(current.policy || {}, null, 2), placeholder: "{\"actions\":{\"max_cards\":1}}" },
|
||
{ name: "reason", label: "变更原因", type: "textarea", rows: 3, value: "", placeholder: "例如:抖音只保留 1 条首页动作,优先高分作品拆解" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/oneliner/governance/user/platforms/${encodeURIComponent(normalizedPlatform)}`, {
|
||
method: "PUT",
|
||
body: {
|
||
project_id: project.id,
|
||
title: values.title || `${platformLabel(normalizedPlatform)} 用户平台策略`,
|
||
summary: values.summary || "",
|
||
policy: parsePolicyJsonField(values.policyJson, "平台策略 JSON"),
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.userCurrentPlatformPolicy = saved;
|
||
await loadAgentControlSurfaces(project.id);
|
||
rememberAction(`${platformLabel(normalizedPlatform)} 平台策略已保存`, `已发布版本 ${saved.current_version?.version_no || 1}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openUserGlobalPolicyHistoryAction() {
|
||
const project = requireSelectedProject();
|
||
const history = await loadPolicyVersions(`/v2/oneliner/governance/user/global/versions?project_id=${encodeURIComponent(project.id)}`);
|
||
const selectedVersionId = history.items[0]?.id || "";
|
||
const versionOptions = buildPolicyVersionOptions(history);
|
||
openActionModal({
|
||
title: "我的全局策略历史",
|
||
description: "查看你自己的全局策略版本,并从历史里选择一个版本回滚。回滚不会改旧记录,而是会生成一个新的生效版本。",
|
||
submitLabel: "回滚到所选版本",
|
||
hideSubmit: !selectedVersionId,
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(appState.userGlobalPolicy || {}, "你还没有发布自己的全局策略。") },
|
||
{ type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, "你的全局策略还没有历史版本。") },
|
||
...(selectedVersionId ? [
|
||
{ name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: versionOptions },
|
||
{ name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到更稳妥的首页动作和语气策略" }
|
||
] : [])
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch("/v2/oneliner/governance/user/global/rollback", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: project.id,
|
||
version_id: values.versionId || selectedVersionId,
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.userGlobalPolicy = saved;
|
||
await loadAgentControlSurfaces(project.id);
|
||
rememberAction("我的全局策略已回滚", `已生成回滚版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openUserPlatformPolicyHistoryAction(platform) {
|
||
const normalizedPlatform = normalizePlatformValue(platform || getPreferredPlatform(), "douyin");
|
||
const project = requireSelectedProject();
|
||
const history = await loadPolicyVersions(`/v2/oneliner/governance/user/platforms/${encodeURIComponent(normalizedPlatform)}/versions?project_id=${encodeURIComponent(project.id)}`);
|
||
const selectedVersionId = history.items[0]?.id || "";
|
||
const versionOptions = buildPolicyVersionOptions(history);
|
||
openActionModal({
|
||
title: `${platformLabel(normalizedPlatform)} 平台策略历史`,
|
||
description: "查看该平台的个人策略版本,并从历史里选择一个版本回滚。回滚只影响当前平台,不会改动其他平台。",
|
||
submitLabel: "回滚到所选版本",
|
||
hideSubmit: !selectedVersionId,
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(appState.userCurrentPlatformPolicy || {}, `你还没有发布 ${platformLabel(normalizedPlatform)} 平台策略。`) },
|
||
{ type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, `${platformLabel(normalizedPlatform)} 还没有历史版本。`) },
|
||
...(selectedVersionId ? [
|
||
{ name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: versionOptions },
|
||
{ name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到更适合这个平台的拆解方式" }
|
||
] : [])
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/oneliner/governance/user/platforms/${encodeURIComponent(normalizedPlatform)}/rollback`, {
|
||
method: "POST",
|
||
body: {
|
||
project_id: project.id,
|
||
version_id: values.versionId || selectedVersionId,
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.userCurrentPlatformPolicy = saved;
|
||
await loadAgentControlSurfaces(project.id);
|
||
rememberAction(`${platformLabel(normalizedPlatform)} 平台策略已回滚`, `已生成回滚版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openSystemMainPolicyAction() {
|
||
if (!ensureAdminGovernanceAccess()) return;
|
||
const projectId = getOneLinerProjectId();
|
||
const bundle = appState.adminSystemMainPolicy || {};
|
||
const current = bundle.current_version || {};
|
||
openActionModal({
|
||
title: "编辑系统主 Agent 策略",
|
||
description: "这是所有用户共享的系统级主 Agent 基座能力,后续用户层和管理员覆盖都会叠加在它上面。",
|
||
submitLabel: "保存系统策略",
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, "系统主 Agent 还没有系统默认策略。") },
|
||
{ name: "title", label: "策略标题", value: current.title || bundle.scope?.title || "系统主 Agent 策略", placeholder: "例如:StoryForge 主 Agent 默认策略" },
|
||
{ name: "summary", label: "摘要", type: "textarea", rows: 3, value: current.summary || "", placeholder: "写清楚当前系统主 Agent 主要服务的方向和约束" },
|
||
{ name: "policyJson", label: "策略 JSON", type: "textarea", rows: 8, value: JSON.stringify(current.policy || {}, null, 2), placeholder: "{\"homepage\":{\"focus\":\"ops\"}}" },
|
||
{ name: "reason", label: "发布原因", type: "textarea", rows: 3, value: "", placeholder: "例如:更新市场节奏后,需要调整首页推荐和调度逻辑" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch("/v2/admin/oneliner/governance/system/main-agent", {
|
||
method: "PUT",
|
||
body: {
|
||
title: values.title || "系统主 Agent 策略",
|
||
summary: values.summary || "",
|
||
policy: parsePolicyJsonField(values.policyJson, "系统策略 JSON"),
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.adminSystemMainPolicy = saved;
|
||
await loadAgentControlSurfaces(projectId);
|
||
rememberAction("系统主 Agent 策略已保存", `已发布版本 ${saved.current_version?.version_no || 1}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openSystemPlatformPolicyAction(platform) {
|
||
if (!ensureAdminGovernanceAccess()) return;
|
||
const normalizedPlatform = normalizePlatformValue(platform, "douyin");
|
||
const projectId = getOneLinerProjectId();
|
||
const bundle = safeArray(appState.adminSystemPlatformPolicies).find((item) => item?.scope?.platform === normalizedPlatform) || {};
|
||
const current = bundle.current_version || {};
|
||
openActionModal({
|
||
title: `编辑 ${platformLabel(normalizedPlatform)} 系统平台策略`,
|
||
description: "这是所有用户共享的系统级平台默认策略,用户自己的平台偏好会在这层之上覆盖。",
|
||
submitLabel: "保存平台默认策略",
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, `当前 ${platformLabel(normalizedPlatform)} 还没有系统平台默认策略。`) },
|
||
{ name: "title", label: "策略标题", value: current.title || `${platformLabel(normalizedPlatform)} 系统平台策略`, placeholder: "例如:抖音系统平台策略" },
|
||
{ name: "summary", label: "摘要", type: "textarea", rows: 3, value: current.summary || "", placeholder: "写清楚这个平台默认遵循的拆解与执行逻辑" },
|
||
{ name: "policyJson", label: "策略 JSON", type: "textarea", rows: 8, value: JSON.stringify(current.policy || {}, null, 2), placeholder: "{\"douyin\":{\"benchmark_mode\":\"strict\"}}" },
|
||
{ name: "reason", label: "发布原因", type: "textarea", rows: 3, value: "", placeholder: "例如:平台节奏变化,需要调整系统默认方法论" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(normalizedPlatform)}`, {
|
||
method: "PUT",
|
||
body: {
|
||
title: values.title || `${platformLabel(normalizedPlatform)} 系统平台策略`,
|
||
summary: values.summary || "",
|
||
policy: parsePolicyJsonField(values.policyJson, "平台默认策略 JSON"),
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.adminSystemPlatformPolicies = safeArray(appState.adminSystemPlatformPolicies)
|
||
.filter((item) => item?.scope?.platform !== normalizedPlatform)
|
||
.concat(saved)
|
||
.sort((a, b) => String(a?.scope?.platform || "").localeCompare(String(b?.scope?.platform || "")));
|
||
await loadAgentControlSurfaces(projectId);
|
||
rememberAction(`${platformLabel(normalizedPlatform)} 系统平台策略已保存`, `已发布版本 ${saved.current_version?.version_no || 1}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openAdminOverrideTargetAction() {
|
||
if (!ensureAdminGovernanceAccess()) return;
|
||
const current = getAdminOverrideTargetState();
|
||
const directoryItems = getAdminGovernanceDirectoryItems();
|
||
openActionModal({
|
||
title: "选择管理员覆盖目标",
|
||
description: "先选中要覆盖的用户、项目和平台,再去编辑覆盖策略或查看历史。",
|
||
submitLabel: "保存目标",
|
||
hideSubmit: !directoryItems.length,
|
||
fields: [
|
||
{ type: "html", label: "当前目标", html: renderPolicyVersionSummary(appState.adminOverridePolicy || {}, `当前目标是 ${formatAdminGovernanceTargetLabel(current)}。`) },
|
||
...(directoryItems.length ? [
|
||
{ name: "targetUserId", label: "目标用户", type: "select", value: current.targetUserId, options: getAdminGovernanceDirectoryUserOptions() },
|
||
{ name: "targetProjectId", label: "目标项目", type: "select", value: current.targetProjectId, options: [{ value: "", label: "用户全局" }, ...getAdminGovernanceDirectoryProjectOptions(current.targetUserId)] },
|
||
{ name: "platform", label: "平台", type: "select", value: current.platform, options: getPlatformOptions() }
|
||
] : []),
|
||
{ type: "html", label: "目录提示", html: directoryItems.length ? `<div class="task-item compact"><h4>可选目标</h4><p>当前目录里有 ${escapeHtml(formatNumber(directoryItems.length))} 位已审核账号。</p></div>` : `<div class="task-item compact"><h4>目录为空</h4><p>后端还没有返回可选账号。</p></div>` }
|
||
],
|
||
onOpen: () => {
|
||
const userSelect = document.querySelector('[data-action-field="targetUserId"]');
|
||
if (!(userSelect instanceof HTMLSelectElement)) return;
|
||
syncAdminOverrideProjectOptions(userSelect.value, current.targetProjectId);
|
||
userSelect.addEventListener("change", () => {
|
||
syncAdminOverrideProjectOptions(userSelect.value, "");
|
||
});
|
||
},
|
||
onSubmit: async (values) => {
|
||
appState.adminOverrideTarget = {
|
||
targetUserId: String(values.targetUserId || ""),
|
||
targetProjectId: String(values.targetProjectId || ""),
|
||
platform: normalizePlatformValue(values.platform || "douyin", "douyin")
|
||
};
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
rememberAction("管理员覆盖目标已更新", `当前目标已切换到 ${formatAdminGovernanceTargetLabel(appState.adminOverrideTarget)}。`, "green");
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function syncAdminOverrideProjectOptions(targetUserId, preferredProjectId = "") {
|
||
const projectSelect = document.querySelector('[data-action-field="targetProjectId"]');
|
||
if (!(projectSelect instanceof HTMLSelectElement)) return;
|
||
const options = [{ value: "", label: "用户全局" }, ...getAdminGovernanceDirectoryProjectOptions(targetUserId)];
|
||
projectSelect.innerHTML = options.map((option) => `
|
||
<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>
|
||
`).join("");
|
||
const normalizedPreferred = String(preferredProjectId ?? "");
|
||
const nextValue = normalizedPreferred === "" || options.some((option) => String(option.value) === normalizedPreferred)
|
||
? normalizedPreferred
|
||
: String(options[0]?.value || "");
|
||
projectSelect.value = nextValue;
|
||
}
|
||
|
||
function openAdminOverridePolicyAction() {
|
||
const target = getAdminOverrideTargetState();
|
||
if (!ensureAdminOverrideTargetReady(target)) return;
|
||
const bundle = appState.adminOverridePolicy || {};
|
||
const current = bundle.current_version || {};
|
||
openActionModal({
|
||
title: "编辑管理员覆盖策略",
|
||
description: "这层策略只作用于当前选中的目标,会叠加在用户策略和系统默认之上。",
|
||
submitLabel: "保存覆盖策略",
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, `当前还没有为 ${formatAdminGovernanceTargetLabel(target)} 发布覆盖策略。`) },
|
||
{ name: "title", label: "策略标题", value: current.title || `管理员覆盖:${formatAdminGovernanceTargetLabel(target)}`, placeholder: "例如:重点账号短期放量覆盖" },
|
||
{ name: "summary", label: "摘要", type: "textarea", rows: 3, value: current.summary || "", placeholder: "写清楚这层覆盖是为了什么目标" },
|
||
{ name: "policyJson", label: "策略 JSON", type: "textarea", rows: 8, value: JSON.stringify(current.policy || {}, null, 2), placeholder: "{\"guardrails\":{\"require_admin_review\":true}}" },
|
||
{ name: "reason", label: "变更原因", type: "textarea", rows: 3, value: "", placeholder: "例如:对该账号/项目临时放宽首页动作数量" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch("/v2/admin/oneliner/governance/overrides", {
|
||
method: "POST",
|
||
body: {
|
||
target_user_id: target.targetUserId,
|
||
target_project_id: target.targetProjectId,
|
||
platform: target.platform,
|
||
title: values.title || `管理员覆盖:${formatAdminGovernanceTargetLabel(target)}`,
|
||
summary: values.summary || "",
|
||
policy: parsePolicyJsonField(values.policyJson, "管理员覆盖策略 JSON"),
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.adminOverridePolicy = saved;
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
rememberAction("管理员覆盖策略已保存", `已为 ${formatAdminGovernanceTargetLabel(target)} 发布版本 ${saved.current_version?.version_no || 1}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openAdminOverrideHistoryAction() {
|
||
const target = getAdminOverrideTargetState();
|
||
if (!ensureAdminOverrideTargetReady(target)) return;
|
||
const history = await loadPolicyVersions(`/v2/admin/oneliner/governance/overrides/versions?target_user_id=${encodeURIComponent(target.targetUserId)}&target_project_id=${encodeURIComponent(target.targetProjectId)}&platform=${encodeURIComponent(target.platform)}`);
|
||
const selectedVersionId = history.items[0]?.id || "";
|
||
openActionModal({
|
||
title: "管理员覆盖历史",
|
||
description: "查看当前目标的管理员覆盖版本,并从历史里选择一个版本回滚。",
|
||
submitLabel: "回滚到所选版本",
|
||
hideSubmit: !selectedVersionId,
|
||
fields: [
|
||
{ type: "html", label: "当前目标", html: renderPolicyVersionSummary(appState.adminOverridePolicy || {}, `当前查看的是 ${formatAdminGovernanceTargetLabel(target)} 的覆盖历史。`) },
|
||
{ type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, "当前目标还没有历史版本。") },
|
||
...(selectedVersionId ? [
|
||
{ name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) },
|
||
{ name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:这版覆盖太激进,需要恢复到上一版" }
|
||
] : [])
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch("/v2/admin/oneliner/governance/overrides/rollback", {
|
||
method: "POST",
|
||
body: {
|
||
target_user_id: target.targetUserId,
|
||
target_project_id: target.targetProjectId,
|
||
platform: target.platform,
|
||
version_id: values.versionId || selectedVersionId,
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.adminOverridePolicy = saved;
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
rememberAction("管理员覆盖已回滚", `已回滚到版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openSystemMainPolicyHistoryAction() {
|
||
if (!ensureAdminGovernanceAccess()) return;
|
||
const history = await loadPolicyVersions("/v2/admin/oneliner/governance/system/main-agent/versions");
|
||
const selectedVersionId = history.items[0]?.id || "";
|
||
openActionModal({
|
||
title: "系统主 Agent 历史",
|
||
description: "查看系统主 Agent 的历史版本,并选择某个版本回滚。",
|
||
submitLabel: "回滚到所选版本",
|
||
hideSubmit: !selectedVersionId,
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(appState.adminSystemMainPolicy || {}, "系统主 Agent 还没有历史版本。") },
|
||
{ type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, "系统主 Agent 还没有历史版本。") },
|
||
...(selectedVersionId ? [
|
||
{ name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) },
|
||
{ name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版系统主 Agent 策略" }
|
||
] : [])
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch("/v2/admin/oneliner/governance/system/main-agent/rollback", {
|
||
method: "POST",
|
||
body: {
|
||
version_id: values.versionId || selectedVersionId,
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.adminSystemMainPolicy = saved;
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
rememberAction("系统主 Agent 已回滚", `已回滚到版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openSystemPlatformPolicyHistoryAction(platform) {
|
||
if (!ensureAdminGovernanceAccess()) return;
|
||
const normalizedPlatform = normalizePlatformValue(platform || getPreferredPlatform(), "douyin");
|
||
const history = await loadPolicyVersions(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(normalizedPlatform)}/versions`);
|
||
const selectedVersionId = history.items[0]?.id || "";
|
||
const bundle = safeArray(appState.adminSystemPlatformPolicies).find((item) => item?.scope?.platform === normalizedPlatform) || {};
|
||
openActionModal({
|
||
title: `${platformLabel(normalizedPlatform)} 系统平台历史`,
|
||
description: "查看该平台的系统默认策略历史,并选择某个版本回滚。",
|
||
submitLabel: "回滚到所选版本",
|
||
hideSubmit: !selectedVersionId,
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, `当前 ${platformLabel(normalizedPlatform)} 还没有系统平台历史版本。`) },
|
||
{ type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, `${platformLabel(normalizedPlatform)} 还没有历史版本。`) },
|
||
...(selectedVersionId ? [
|
||
{ name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) },
|
||
{ name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版平台默认方法论" }
|
||
] : [])
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(normalizedPlatform)}/rollback`, {
|
||
method: "POST",
|
||
body: {
|
||
version_id: values.versionId || selectedVersionId,
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.adminSystemPlatformPolicies = safeArray(appState.adminSystemPlatformPolicies)
|
||
.filter((item) => item?.scope?.platform !== normalizedPlatform)
|
||
.concat(saved)
|
||
.sort((a, b) => String(a?.scope?.platform || "").localeCompare(String(b?.scope?.platform || "")));
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
rememberAction(`${platformLabel(normalizedPlatform)} 系统平台策略已回滚`, `已回滚到版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openPlatformAgentProfileAction(platform) {
|
||
const project = requireSelectedProject();
|
||
const agents = safeArray(appState.platformAgents);
|
||
const current = agents.find((item) => item.platform === platform) || {};
|
||
const assistants = getAssistantOptions(project.id);
|
||
openActionModal({
|
||
title: `配置 ${platformLabel(platform)} Agent`,
|
||
description: "给这个平台绑定自己的执行 Agent,并补充任务目标和方法论定位。",
|
||
submitLabel: "保存平台 Agent",
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(current, `当前 ${platformLabel(platform)} Agent 还没有历史版本。`) },
|
||
{ name: "assistantId", label: "绑定执行 Agent", type: "select", value: current.assistant_id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] },
|
||
{ name: "name", label: "名称", value: current.name || `${platformLabel(platform)} Agent`, placeholder: "例如:快手增长 Agent" },
|
||
{ name: "mission", label: "任务目标", type: "textarea", rows: 4, value: current.mission || "", placeholder: "例如:沉淀快手平台的开场结构、停留逻辑和转化方法论" },
|
||
{ name: "notes", label: "补充说明", type: "textarea", rows: 4, value: current.notes || "", placeholder: "例如:优先观察短句节奏、直播切片和成交句式" },
|
||
{ name: "status", label: "状态", type: "select", value: current.status || "active", options: [{ value: "active", label: "启用" }, { value: "draft", label: "草稿" }, { value: "paused", label: "暂停" }] },
|
||
{ name: "reason", label: "变更原因", type: "textarea", rows: 3, value: "", placeholder: "例如:调整当前平台 Agent 的拆解重点和执行方向" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(platform)}/profile`, {
|
||
method: "PUT",
|
||
body: {
|
||
project_id: project.id,
|
||
assistant_id: values.assistantId || "",
|
||
name: values.name || `${platformLabel(platform)} Agent`,
|
||
mission: values.mission || "",
|
||
notes: values.notes || "",
|
||
status: values.status || "active",
|
||
reason: values.reason || "",
|
||
config: {
|
||
self_optimize: true,
|
||
tenant_scoped_memory: true,
|
||
ui_escalation_via_oneliner: true
|
||
}
|
||
}
|
||
});
|
||
appState.platformAgents = safeArray(appState.platformAgents).filter((item) => item.platform !== platform).concat(saved).sort((a, b) => String(a.platform).localeCompare(String(b.platform)));
|
||
rememberAction("平台 Agent 已保存", `已更新 ${platformLabel(platform)} Agent,当前版本 ${saved.current_version?.version_no || 1}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openPlatformAgentProfileHistoryAction(platform) {
|
||
const project = requireSelectedProject();
|
||
const normalizedPlatform = normalizePlatformValue(platform || getPreferredPlatform(), "douyin");
|
||
const history = await loadPolicyVersions(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/profile/versions?project_id=${encodeURIComponent(project.id)}`);
|
||
const audits = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/profile/audits?project_id=${encodeURIComponent(project.id)}`).catch(() => ({ items: [] }));
|
||
const current = safeArray(appState.platformAgents).find((item) => item.platform === normalizedPlatform) || {};
|
||
const selectedVersionId = history.items[0]?.id || "";
|
||
openActionModal({
|
||
title: `${platformLabel(normalizedPlatform)} Agent 配置历史`,
|
||
description: "查看平台 Agent 配置版本,并从历史里选择一个版本回滚。",
|
||
submitLabel: "回滚到所选版本",
|
||
hideSubmit: !selectedVersionId,
|
||
fields: [
|
||
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(current, `当前 ${platformLabel(normalizedPlatform)} Agent 还没有历史版本。`) },
|
||
{ type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, `当前 ${platformLabel(normalizedPlatform)} Agent 还没有历史版本。`) },
|
||
{ type: "html", label: "审计记录", html: renderPolicyAuditsHtml(safeArray(audits.items || audits), "当前还没有审计记录。") },
|
||
...(selectedVersionId ? [
|
||
{ name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) },
|
||
{ name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版平台 Agent 方法论" }
|
||
] : [])
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/profile/rollback`, {
|
||
method: "POST",
|
||
body: {
|
||
project_id: project.id,
|
||
version_id: values.versionId || selectedVersionId,
|
||
reason: values.reason || ""
|
||
}
|
||
});
|
||
appState.platformAgents = safeArray(appState.platformAgents).filter((item) => item.platform !== normalizedPlatform).concat(saved).sort((a, b) => String(a.platform).localeCompare(String(b.platform)));
|
||
rememberAction(`${platformLabel(normalizedPlatform)} Agent 已回滚`, `已回滚到版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openPlatformAgentMemoryAction(platform) {
|
||
const project = requireSelectedProject();
|
||
openActionModal({
|
||
title: `补充 ${platformLabel(platform)} Agent 记忆`,
|
||
description: "把当前阶段已经验证有效的平台方法、结论或注意事项沉淀为租户级长期记忆。",
|
||
submitLabel: "保存记忆",
|
||
fields: [
|
||
{ name: "memoryKey", label: "记忆键", value: "lesson.current", placeholder: "例如:hook.pattern.v1" },
|
||
{ name: "title", label: "标题", placeholder: "例如:快手直播切片更吃冲突前置" },
|
||
{ name: "summary", label: "摘要", type: "textarea", rows: 4, placeholder: "写清楚这条记忆的结论和适用场景" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.memoryKey?.trim()) throw new Error("请填写记忆键");
|
||
if (!values.summary?.trim()) throw new Error("请填写记忆摘要");
|
||
const saved = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(platform)}/memories`, {
|
||
method: "POST",
|
||
body: {
|
||
project_id: project.id,
|
||
memory_key: values.memoryKey.trim(),
|
||
title: values.title || values.memoryKey.trim(),
|
||
summary: values.summary.trim(),
|
||
subject_type: "project",
|
||
subject_id: project.id,
|
||
details: {
|
||
source: "manual-ui",
|
||
platform,
|
||
captured_at: new Date().toISOString()
|
||
},
|
||
confidence: 0.82
|
||
}
|
||
});
|
||
rememberAction("平台记忆已保存", `已把这条方法沉淀到 ${platformLabel(platform)} Agent 记忆中。`, "green", saved);
|
||
await loadAgentControlSurfaces(project.id);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openPlatformAgentSkillAction(platform) {
|
||
const project = requireSelectedProject();
|
||
openActionModal({
|
||
title: `补充 ${platformLabel(platform)} Agent 技能`,
|
||
description: "把子 Agent 当前阶段验证过的方法论固化成可复用技能,并保留测试规范。",
|
||
submitLabel: "保存技能",
|
||
fields: [
|
||
{ name: "skillKey", label: "技能键", value: "skill.current", placeholder: "例如:crawler.profile.dom.v2" },
|
||
{ name: "name", label: "名称", placeholder: "例如:主页结构适配技能" },
|
||
{ name: "status", label: "状态", type: "select", value: "validated", options: [{ value: "draft", label: "草稿" }, { value: "validated", label: "已验证" }, { value: "paused", label: "暂停" }] },
|
||
{ name: "method", label: "方法摘要", type: "textarea", rows: 4, placeholder: "写清楚当前方法是怎么拿到结果的" },
|
||
{ name: "testSpec", label: "验收标准", type: "textarea", rows: 4, placeholder: "例如:主页抓取成功率 >= 95%,作品标题和发布时间都齐全" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.skillKey?.trim()) throw new Error("请填写技能键");
|
||
if (!values.name?.trim()) throw new Error("请填写技能名称");
|
||
const saved = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(platform)}/skills`, {
|
||
method: "POST",
|
||
body: {
|
||
project_id: project.id,
|
||
skill_key: values.skillKey.trim(),
|
||
name: values.name.trim(),
|
||
status: values.status || "validated",
|
||
method: { summary: values.method || "" },
|
||
test_spec: { summary: values.testSpec || "" },
|
||
last_result: { source: "manual-ui" },
|
||
success_count: 1,
|
||
failure_count: 0,
|
||
last_score: 0.9
|
||
}
|
||
});
|
||
rememberAction("平台技能已保存", `已把方法固化到 ${platformLabel(platform)} Agent 技能中。`, "green", saved);
|
||
await loadAgentControlSurfaces(project.id);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openPlatformAgentDetailAction(platform) {
|
||
const project = requireSelectedProject();
|
||
const normalizedPlatform = normalizePlatformValue(platform, getPreferredPlatform());
|
||
const profile = safeArray(appState.platformAgents).find((item) => item.platform === normalizedPlatform) || null;
|
||
if (!profile) {
|
||
rememberAction("平台 Agent 不存在", "当前没有找到这条平台 Agent,请先刷新当前页面。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
const [memoriesPayload, skillsPayload] = await Promise.all([
|
||
storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/memories?project_id=${encodeURIComponent(project.id)}`).catch(() => ({ items: [] })),
|
||
storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/skills?project_id=${encodeURIComponent(project.id)}`).catch(() => ({ items: [] }))
|
||
]);
|
||
const memories = safeArray(memoriesPayload?.items || memoriesPayload).slice(0, 6);
|
||
const skills = safeArray(skillsPayload?.items || skillsPayload).slice(0, 6);
|
||
const skillVersionEntries = await Promise.all(
|
||
skills.map(async (item) => {
|
||
const payload = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/skills/${encodeURIComponent(item.id)}/versions?project_id=${encodeURIComponent(project.id)}`).catch(() => ({ items: [] }));
|
||
return [item.id, safeArray(payload?.items || payload).slice(0, 3)];
|
||
})
|
||
);
|
||
const skillVersions = Object.fromEntries(skillVersionEntries);
|
||
openActionModal({
|
||
title: `${platformLabel(normalizedPlatform)} Agent 详情`,
|
||
description: "查看当前平台 Agent 最近沉淀的记忆、技能和就绪度。",
|
||
hideSubmit: true,
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "详情",
|
||
html: `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(profile.name || `${platformLabel(normalizedPlatform)} Agent`)}</h4>
|
||
<p>${escapeHtml(profile.mission || profile.notes || "暂无任务目标说明")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${profile.status === "active" ? "green" : "blue"}">${escapeHtml(profile.status || "draft")}</span>
|
||
${profile.readiness_label ? `<span class="tag ${profile.readiness_score >= 75 ? "green" : profile.readiness_score >= 50 ? "blue" : "orange"}">${escapeHtml(profile.readiness_label)} ${escapeHtml(formatNumber(profile.readiness_score || 0))}</span>` : ""}
|
||
<span class="tag">${escapeHtml(profile.assistant?.name || "未绑执行 Agent")}</span>
|
||
</div>
|
||
</div>
|
||
${profile.recent_execution?.run_id ? `
|
||
<div class="task-item compact" style="margin-top:12px;">
|
||
<h4>最近执行</h4>
|
||
<p>${escapeHtml(profile.recent_execution.summary || "最近一次主 Agent 执行已回写到当前平台 Agent。")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(profile.recent_execution.intent_label || "主 Agent 任务")}</span>
|
||
<span class="tag">${escapeHtml(profile.recent_execution.run_status || "done")}</span>
|
||
${profile.recent_execution.workstream_label ? `<span class="tag green">${escapeHtml(profile.recent_execution.workstream_label)}</span>` : ""}
|
||
${profile.recent_execution.oneliner_profile_version_no ? `<span class="tag">配置 v${escapeHtml(formatNumber(profile.recent_execution.oneliner_profile_version_no))}</span>` : ""}
|
||
${profile.recent_execution.platform_agent_profile_version_no ? `<span class="tag">${escapeHtml(platformLabel(normalizedPlatform))} Agent v${escapeHtml(formatNumber(profile.recent_execution.platform_agent_profile_version_no))}</span>` : ""}
|
||
${profile.recent_execution.source_screen ? `<span class="tag">${escapeHtml(screenLabel(profile.recent_execution.source_screen) || profile.recent_execution.source_screen)}</span>` : ""}
|
||
</div>
|
||
<div class="task-meta" style="margin-top:8px;">
|
||
${profile.recent_execution.recommended_action?.action ? `<span class="tag clickable-tag" data-action="${escapeHtml(profile.recent_execution.recommended_action.action)}" ${buildMainAgentLandingAttrs({ runId: profile.recent_execution.run_id, screen: profile.recent_execution.recommended_action.screen || profile.recent_execution.source_screen, title: profile.recent_execution.recommended_action.label || "回到业务页", summary: profile.recent_execution.recommended_action.summary || profile.recent_execution.summary || "" })}>${escapeHtml(profile.recent_execution.recommended_action.label || "回到业务页")}</span>` : ""}
|
||
<span class="tag clickable-tag" data-action="open-oneliner-run-result" data-run-id="${escapeHtml(profile.recent_execution.run_id)}">查看执行结果</span>
|
||
<span class="tag clickable-tag" data-action="open-oneliner-run-context" data-run-id="${escapeHtml(profile.recent_execution.run_id)}">回到主 Agent 查看</span>
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
<div class="two-col" style="margin-top:12px;">
|
||
<div>
|
||
<div class="panel-subtitle">最近记忆</div>
|
||
<div class="list" style="margin-top:8px;">
|
||
${memories.map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.title || item.memory_key || "未命名")}</h4>
|
||
<p>${escapeHtml(item.summary || "暂无摘要")}</p>
|
||
<div class="task-meta"><span class="tag blue">${escapeHtml(item.memory_key || "memory")}</span><span class="tag">${escapeHtml(formatNumber(item.confidence || 0))}</span></div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>还没有平台记忆</h4><p>先把这段时间验证有效的方法沉淀进来。</p></div>`}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="panel-subtitle">最近技能</div>
|
||
<div class="list" style="margin-top:8px;">
|
||
${skills.map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.name || item.skill_key || "未命名")}</h4>
|
||
<p>${escapeHtml(item.test_spec?.summary || item.method?.summary || "暂无方法摘要")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${item.status === "validated" ? "green" : item.status === "needs_revision" ? "orange" : "blue"}">${escapeHtml(item.status || "draft")}</span>
|
||
<span class="tag blue">得分 ${escapeHtml(formatNumber(item.last_score || 0))}</span>
|
||
<span class="tag clickable-tag" data-action="review-platform-skill" data-platform="${escapeHtml(normalizedPlatform)}" data-skill-id="${escapeHtml(item.id || "")}" data-accepted="true">验收通过</span>
|
||
<span class="tag clickable-tag" data-action="review-platform-skill" data-platform="${escapeHtml(normalizedPlatform)}" data-skill-id="${escapeHtml(item.id || "")}" data-accepted="false">标记待优化</span>
|
||
</div>
|
||
${safeArray(skillVersions[item.id]).length ? `
|
||
<div class="task-meta" style="margin-top:8px;">
|
||
${safeArray(skillVersions[item.id]).map((version, index) => `
|
||
<span class="tag ${index === 0 ? "blue" : ""}${index !== 0 ? "" : ""} ${index === 0 ? "" : "clickable-tag"}"
|
||
${index === 0 ? "" : `data-action="rollback-platform-skill" data-platform="${escapeHtml(normalizedPlatform)}" data-skill-id="${escapeHtml(item.id || "")}" data-version-id="${escapeHtml(version.id || "")}"`}>
|
||
${escapeHtml(`v${formatNumber(version.version_no || 0)} · ${version.snapshot_reason || "snapshot"}`)}
|
||
</span>
|
||
`).join("")}
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>还没有平台技能</h4><p>等子 Agent 跑出稳定结果后,把方法固化成技能。</p></div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="task-meta" style="margin-top:12px;">
|
||
<span class="tag clickable-tag" data-action="run-oneliner-action" data-executor-key="platform-self-check" data-platform="${escapeHtml(normalizedPlatform)}">运行平台自检</span>
|
||
<span class="tag clickable-tag" data-action="open-platform-agent-profile" data-platform="${escapeHtml(normalizedPlatform)}">编辑配置</span>
|
||
<span class="tag clickable-tag" data-action="open-platform-agent-profile-history" data-platform="${escapeHtml(normalizedPlatform)}">看配置历史</span>
|
||
<span class="tag clickable-tag" data-action="open-platform-agent-memory" data-platform="${escapeHtml(normalizedPlatform)}">继续补记忆</span>
|
||
<span class="tag clickable-tag" data-action="open-platform-agent-skill" data-platform="${escapeHtml(normalizedPlatform)}">继续补技能</span>
|
||
<span class="tag clickable-tag" data-action="handoff-to-main-agent" data-platform="${escapeHtml(normalizedPlatform)}" data-source-screen="playbook" data-source-action-key="platform-agent-handoff" data-intent-key="custom" data-title="继续完善平台 Agent" data-goal="继续完善平台 Agent" data-summary="让主 Agent 结合当前平台记忆和技能,给出下一步执行计划。" data-plan-steps="${escapeHtml(JSON.stringify(["读取当前平台 Agent 配置", "检查记忆与技能缺口", "生成下一步执行计划"]))}">交给主 Agent 继续</span>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
function openPlatformSkillReviewAction(platform, skillId, accepted) {
|
||
const project = requireSelectedProject();
|
||
const normalizedPlatform = normalizePlatformValue(platform, getPreferredPlatform());
|
||
const profile = safeArray(appState.platformAgents).find((item) => item.platform === normalizedPlatform) || null;
|
||
const skill = safeArray(profile?.recent_skill ? [profile.recent_skill] : [])
|
||
.concat([])
|
||
.find((item) => item.id === skillId)
|
||
|| null;
|
||
openActionModal({
|
||
title: accepted ? "验收平台技能" : "标记技能待优化",
|
||
description: accepted
|
||
? `把这条 ${platformLabel(normalizedPlatform)} 技能标记为当前可复用的方法。`
|
||
: `这条 ${platformLabel(normalizedPlatform)} 技能暂时不通过,要求继续优化。`,
|
||
submitLabel: accepted ? "确认通过" : "确认待优化",
|
||
fields: [
|
||
{ name: "summary", label: "结论摘要", placeholder: accepted ? "例如:当前抓取结果和验收数据一致,可固化成技能" : "例如:账号匹配不稳定,需要继续优化抓取方式" },
|
||
{ name: "reviewNotes", label: "审计备注", type: "textarea", rows: 4, value: skill?.last_result?.review_notes || "", placeholder: "写清楚为什么通过或退回" },
|
||
{ name: "score", label: "得分", type: "number", value: accepted ? 0.9 : 0.45, min: 0, max: 1, step: 0.05 }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/skills/${encodeURIComponent(skillId)}/review`, {
|
||
method: "POST",
|
||
body: {
|
||
project_id: project.id,
|
||
accepted,
|
||
score: Number(values.score || (accepted ? 0.9 : 0.45)),
|
||
summary: values.summary || "",
|
||
review_notes: values.reviewNotes || ""
|
||
}
|
||
});
|
||
rememberAction(
|
||
accepted ? "平台技能已通过" : "平台技能待优化",
|
||
`技能「${saved.name || saved.skill_key || skillId}」已更新为 ${saved.status || (accepted ? "validated" : "needs_revision")}。`,
|
||
accepted ? "green" : "orange",
|
||
saved
|
||
);
|
||
await loadAgentControlSurfaces(project.id);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openPlatformSkillRollbackAction(platform, skillId, versionId) {
|
||
const project = requireSelectedProject();
|
||
const normalizedPlatform = normalizePlatformValue(platform, getPreferredPlatform());
|
||
openActionModal({
|
||
title: "回滚平台技能",
|
||
description: "把当前技能回退到旧版本,并保留新的回滚快照,方便继续追踪。",
|
||
submitLabel: "确认回滚",
|
||
fields: [
|
||
{
|
||
name: "summary",
|
||
label: "回滚说明",
|
||
type: "html",
|
||
html: `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(platformLabel(normalizedPlatform))} 技能回滚</h4>
|
||
<p>${escapeHtml(`将 skill ${skillId} 回滚到版本 ${versionId}。`)}</p>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
],
|
||
onSubmit: async () => {
|
||
const payload = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/skills/${encodeURIComponent(skillId)}/rollback`, {
|
||
method: "POST",
|
||
body: {
|
||
project_id: project.id,
|
||
version_id: versionId
|
||
}
|
||
});
|
||
rememberAction("技能已回滚", `已回滚到版本 ${payload.rollback_from_version?.version_no || "指定版本"}。`, "green", payload);
|
||
await loadAgentControlSurfaces(project.id);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openActionRegistryEditAction(actionKey) {
|
||
const project = requireSelectedProject();
|
||
const actionDef = safeArray(appState.onelinerActionRegistry).find((item) => item.action_key === actionKey) || null;
|
||
if (!actionDef) {
|
||
rememberAction("动作定义不存在", "当前没有找到这条 OneLiner 动作定义,请先刷新当前页面。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
openActionModal({
|
||
title: "编辑 OneLiner 动作",
|
||
description: "在租户范围内控制动作名称、说明、开关和少量配置。",
|
||
submitLabel: "保存动作",
|
||
fields: [
|
||
{ name: "label", label: "动作名称", value: actionDef.label || "" },
|
||
{ name: "description", label: "动作说明", type: "textarea", rows: 4, value: actionDef.description || "" },
|
||
{ name: "status", label: "状态", type: "select", value: actionDef.status || "enabled", options: [{ value: "enabled", label: "启用" }, { value: "disabled", label: "禁用" }] },
|
||
{ name: "configJson", label: "配置 JSON", type: "textarea", rows: 5, value: JSON.stringify(actionDef.config || {}, null, 2) }
|
||
],
|
||
onSubmit: async (values) => {
|
||
let config = {};
|
||
if (String(values.configJson || "").trim()) {
|
||
config = JSON.parse(values.configJson);
|
||
}
|
||
const saved = await storyforgeFetch(`/v2/oneliner/action-registry/${encodeURIComponent(actionKey)}?project_id=${encodeURIComponent(project.id)}`, {
|
||
method: "PUT",
|
||
body: {
|
||
label: values.label || "",
|
||
description: values.description || "",
|
||
category: actionDef.category || "custom",
|
||
status: values.status || "enabled",
|
||
config
|
||
}
|
||
});
|
||
rememberAction("动作已更新", `OneLiner 动作「${saved.label || saved.action_key}」已保存。`, "green", saved);
|
||
await loadAgentControlSurfaces(project.id);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openTenantQuotaAction() {
|
||
const project = requireSelectedProject();
|
||
const quota = appState.tenantQuota || {};
|
||
openActionModal({
|
||
title: "编辑租户额度",
|
||
description: "当前额度按租户 + 项目隔离,用于商业化预算、动作配额和存储保护。",
|
||
submitLabel: "保存额度",
|
||
fields: [
|
||
{ name: "enabled", label: "启用额度保护", type: "checkbox", value: quota.enabled !== false },
|
||
{ name: "monthlyBudgetCents", label: "月预算(分)", type: "number", value: quota.monthly_budget_cents || 0, min: 0 },
|
||
{ name: "storageLimitBytes", label: "存储上限(字节)", type: "number", value: quota.storage_limit_bytes || 0, min: 0 },
|
||
{ name: "analysisQuota", label: "分析配额", type: "number", value: quota.analysis_quota || 0, min: 0 },
|
||
{ name: "copyQuota", label: "文案配额", type: "number", value: quota.copy_quota || 0, min: 0 },
|
||
{ name: "aiVideoQuota", label: "AI 视频配额", type: "number", value: quota.ai_video_quota || 0, min: 0 },
|
||
{ name: "realCutQuota", label: "实拍剪辑配额", type: "number", value: quota.real_cut_quota || 0, min: 0 },
|
||
{ name: "recorderQuota", label: "录制配额", type: "number", value: quota.recorder_quota || 0, min: 0 }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/tenant/quota?project_id=${encodeURIComponent(project.id)}`, {
|
||
method: "PUT",
|
||
body: {
|
||
enabled: Boolean(values.enabled),
|
||
monthly_budget_cents: Number(values.monthlyBudgetCents || 0),
|
||
storage_limit_bytes: Number(values.storageLimitBytes || 0),
|
||
analysis_quota: Number(values.analysisQuota || 0),
|
||
copy_quota: Number(values.copyQuota || 0),
|
||
ai_video_quota: Number(values.aiVideoQuota || 0),
|
||
real_cut_quota: Number(values.realCutQuota || 0),
|
||
recorder_quota: Number(values.recorderQuota || 0),
|
||
config: quota.config || {}
|
||
}
|
||
});
|
||
rememberAction("租户额度已更新", "当前项目的预算与配额已经保存。", "green", saved);
|
||
await loadAgentControlSurfaces(project.id);
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openCreateAssistantAction() {
|
||
const project = requireSelectedProject();
|
||
const kbOptions = getKnowledgeBaseOptions(project.id);
|
||
const modelOptions = getModelOptions();
|
||
openActionModal({
|
||
title: "创建 Agent",
|
||
description: "先定义用途、平台与目标,再让 Agent 学习内容。",
|
||
submitLabel: "创建 Agent",
|
||
fields: [
|
||
{ name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() },
|
||
{ name: "name", label: "名称", placeholder: "例如:创业成交助手" },
|
||
{ name: "description", label: "说明", placeholder: "例如:服务创业 IP 与成交型短视频" },
|
||
{ name: "goal", label: "生成目标", placeholder: "例如:输出创业口播、对标拆解和成交文案" },
|
||
{ name: "systemPrompt", label: "系统提示词", type: "textarea", rows: 5, placeholder: "可选,不填则后续再补" },
|
||
{ name: "knowledgeBaseId", label: "默认知识库", type: "select", value: kbOptions[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...kbOptions] },
|
||
{ name: "modelProfileId", label: "主模型", type: "select", value: modelOptions.find((item) => item.value === safeArray(appState.dashboard?.model_profiles).find((m) => m.is_default)?.id)?.value || modelOptions[0]?.value || "", options: modelOptions }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.name?.trim()) throw new Error("请填写 Agent 名称");
|
||
const projectId = values.projectId || project.id;
|
||
const assistant = await storyforgeFetch("/v2/assistants", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: projectId,
|
||
name: values.name.trim(),
|
||
description: values.description || "",
|
||
generation_goal: values.goal || "",
|
||
system_prompt: values.systemPrompt || "",
|
||
knowledge_base_ids: values.knowledgeBaseId ? [values.knowledgeBaseId] : [],
|
||
model_profile_id: values.modelProfileId || ""
|
||
}
|
||
});
|
||
appState.selectedAssistantId = assistant.id;
|
||
rememberAction("Agent 已创建", `已创建 Agent「${assistant.name}」。`, "green", assistant);
|
||
await bootstrap();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openEditAssistantAction(assistantId = "") {
|
||
const assistant = safeArray(appState.dashboard?.assistants).find((item) => item.id === assistantId) || getSelectedAssistant();
|
||
if (!assistant) {
|
||
rememberAction("请先选择 Agent", "当前还没有可编辑的 Agent,先在项目里选择或创建一个。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
const modelOptions = getModelOptions();
|
||
openActionModal({
|
||
title: "编辑 Agent",
|
||
description: "更新当前 Agent 的名称、目标和主模型,不会影响已完成任务。",
|
||
submitLabel: "保存 Agent",
|
||
fields: [
|
||
{ name: "name", label: "名称", value: assistant.name || "", placeholder: "例如:创业成交助手" },
|
||
{ name: "description", label: "说明", value: assistant.description || "", placeholder: "例如:服务创业 IP 与成交型短视频" },
|
||
{ name: "goal", label: "生成目标", value: assistant.generation_goal || "", placeholder: "例如:输出创业口播、对标拆解和成交文案" },
|
||
{ name: "systemPrompt", label: "系统提示词", type: "textarea", rows: 5, value: assistant.system_prompt || "", placeholder: "可选,不填则后续再补" },
|
||
{ name: "modelProfileId", label: "主模型", type: "select", value: assistant.model_profile_id || modelOptions[0]?.value || "", options: modelOptions }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.name?.trim()) throw new Error("请填写 Agent 名称");
|
||
const updated = await storyforgeFetch(`/v2/assistants/${encodeURIComponent(assistant.id)}`, {
|
||
method: "PATCH",
|
||
body: {
|
||
name: values.name.trim(),
|
||
description: values.description || "",
|
||
generation_goal: values.goal || "",
|
||
system_prompt: values.systemPrompt || "",
|
||
model_profile_id: values.modelProfileId || ""
|
||
}
|
||
});
|
||
appState.selectedAssistantId = updated.id;
|
||
rememberAction("Agent 已更新", `已更新 Agent「${updated.name}」。`, "green", updated);
|
||
await bootstrap();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openAnalyzeSelectedAccountAction() {
|
||
const account = requireSelectedAccountRow();
|
||
const platform = getAccountPlatform(account);
|
||
const analyzePath = getWorkbenchRoute(platform, "analyzeAccount", account.id);
|
||
openActionModal({
|
||
title: "分析当前对标账号",
|
||
description: "从商业化和内容运营角度重跑一次账号分析。",
|
||
submitLabel: "开始分析",
|
||
fields: [
|
||
{ name: "maxVideos", label: "纳入分析作品数", type: "number", value: 6, min: 3, max: 20 },
|
||
{ name: "extraFocus", label: "额外关注点", type: "textarea", rows: 4, placeholder: "例如:更关注商业化承接与私域转化" },
|
||
{ name: "autoAnalyzeTopVideos", label: "分析后自动补高分作品", type: "checkbox", value: true },
|
||
{ name: "topVideoCount", label: "高分作品分析数", type: "number", value: 4, min: 1, max: 10 }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const result = await storyforgeFetch(analyzePath, {
|
||
method: "POST",
|
||
body: {
|
||
model_profile_ids: [],
|
||
linked_account_ids: [],
|
||
include_linked_accounts: true,
|
||
include_recent_similar_candidates: true,
|
||
max_videos: Number(values.maxVideos || 6),
|
||
extra_focus: values.extraFocus || "",
|
||
temperature: 0.35,
|
||
auto_analyze_top_videos: Boolean(values.autoAnalyzeTopVideos),
|
||
top_video_analysis_count: Number(values.topVideoCount || 4)
|
||
}
|
||
});
|
||
const summary = result?.suggestions?.[0]?.parsed_json?.executive_summary || result?.suggestions?.[0]?.suggestion_text || "已生成新的账号分析。";
|
||
rememberAction("对标账号分析完成", brief(summary, 120), "green", result);
|
||
await loadPlatformAccount(platform, account.id);
|
||
focusDiscoveryInsights();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openAnalyzeTopVideosAction() {
|
||
const account = requireSelectedAccountRow();
|
||
const platform = getAccountPlatform(account);
|
||
const analyzePath = getWorkbenchRoute(platform, "analyzeTopVideos", account.id);
|
||
openActionModal({
|
||
title: "分析高分作品",
|
||
description: "对当前对标账号的高分作品批量补分析。",
|
||
submitLabel: "开始分析",
|
||
fields: [
|
||
{ name: "topVideoCount", label: "分析作品数", type: "number", value: 5, min: 1, max: 12 },
|
||
{ name: "minScore", label: "最低分阈值", type: "number", value: 45, min: 0, max: 100 }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const result = await storyforgeFetch(analyzePath, {
|
||
method: "POST",
|
||
body: {
|
||
model_profile_id: "",
|
||
top_video_count: Number(values.topVideoCount || 5),
|
||
min_score: Number(values.minScore || 45),
|
||
temperature: 0.25
|
||
}
|
||
});
|
||
appState.topVideoAnalysisResults = {
|
||
...(appState.topVideoAnalysisResults || {}),
|
||
[account.id]: {
|
||
...result,
|
||
account_id: account.id,
|
||
platform,
|
||
created_at: new Date().toISOString()
|
||
}
|
||
};
|
||
rememberAction("高分作品分析完成", `已补分析 ${formatNumber(result.analyzed_count)} 条高分作品。`, "green", result);
|
||
await loadPlatformAccount(platform, account.id);
|
||
focusDiscoveryTopVideoInsights();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openSimilaritySearchAction() {
|
||
const account = requireSelectedAccountRow();
|
||
const platform = getAccountPlatform(account);
|
||
const createPath = getWorkbenchRoute(platform, "similarSearches");
|
||
openActionModal({
|
||
title: "查相似账号",
|
||
description: "让 Agent 基于当前账号画像找更多可借鉴对象。",
|
||
submitLabel: "开始查找",
|
||
fields: [
|
||
{ name: "maxCandidates", label: "最多候选数", type: "number", value: 8, min: 3, max: 20 },
|
||
{ name: "extraRequirements", label: "额外要求", type: "textarea", rows: 4, placeholder: "例如:优先找创业成交类、口播结构强的账号" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const created = await storyforgeFetch(createPath, {
|
||
method: "POST",
|
||
body: {
|
||
source_account_id: account.id,
|
||
candidate_urls: [],
|
||
seed_linked_accounts: true,
|
||
search_public_pages: true,
|
||
model_profile_id: "",
|
||
max_candidates: Number(values.maxCandidates || 8),
|
||
extra_requirements: values.extraRequirements || ""
|
||
}
|
||
});
|
||
const searchId = created.id || created.search_id;
|
||
const detailPath = searchId ? getWorkbenchRoute(platform, "similarSearchDetail", searchId) : "";
|
||
const detail = searchId
|
||
? await storyforgeFetch(detailPath)
|
||
: created;
|
||
appState.lastSimilaritySearch = detail;
|
||
rememberAction("相似账号已生成", `已生成 ${formatNumber(safeArray(detail.candidates).length)} 个候选账号。`, "green", detail);
|
||
await loadPlatformAccount(platform, account.id);
|
||
focusDiscoveryRelations();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openBenchmarkLinkAction(defaults = {}) {
|
||
const account = requireSelectedAccountRow();
|
||
const platform = getAccountPlatform(account);
|
||
const benchmarkPath = getWorkbenchRoute(platform, "benchmarkLinks", account.id);
|
||
const options = safeArray(appState.accounts)
|
||
.filter((item) => item.id !== account.id)
|
||
.map((item) => ({ value: item.id, label: getAccountName(item) || item.id }));
|
||
const candidate = typeof defaults.candidateIndex === "number"
|
||
? safeArray(appState.lastSimilaritySearch?.candidates)[defaults.candidateIndex] || null
|
||
: null;
|
||
openActionModal({
|
||
title: "保存对标关系",
|
||
description: "把当前账号和另一个账号关联成对标关系,便于后续持续跟踪。",
|
||
submitLabel: "保存关系",
|
||
fields: [
|
||
{ name: "targetAccountId", label: "目标账号", type: "select", value: defaults.targetAccountId || candidate?.candidate_account_id || options[0]?.value || "", options: [{ value: "", label: "仅保存主页链接" }, ...options] },
|
||
{ name: "targetProfileUrl", label: "目标主页链接", type: "url", value: defaults.targetProfileUrl || candidate?.candidate_profile_url || "", placeholder: "没有本地账号时可直接保存主页链接" },
|
||
{ name: "relationType", label: "关系类型", type: "select", value: "benchmark", options: [
|
||
{ value: "benchmark", label: "对标" },
|
||
{ value: "learn", label: "学习" },
|
||
{ value: "watch", label: "跟踪" }
|
||
] },
|
||
{ name: "note", label: "备注", value: defaults.note || brief(candidate?.rationale_text || "", 120), placeholder: "例如:开场结构很强,适合持续跟踪" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.targetAccountId && !values.targetProfileUrl?.trim()) throw new Error("请先选择一个目标账号或填写主页链接");
|
||
const result = await storyforgeFetch(benchmarkPath, {
|
||
method: "POST",
|
||
body: {
|
||
target_account_ids: values.targetAccountId ? [values.targetAccountId] : [],
|
||
target_profile_urls: values.targetAccountId ? [] : [values.targetProfileUrl.trim()],
|
||
relation_type: values.relationType || "benchmark",
|
||
note: values.note || "",
|
||
search_id: appState.lastSimilaritySearch?.id || ""
|
||
}
|
||
});
|
||
if (candidate) {
|
||
markSavedCandidate(candidate, result.links);
|
||
} else if (appState.selectedWorkspace) {
|
||
appState.selectedWorkspace = {
|
||
...appState.selectedWorkspace,
|
||
linked_accounts: safeArray(result.links)
|
||
};
|
||
}
|
||
rememberAction("对标关系已保存", "当前账号的对标关系已更新。", "green");
|
||
focusDiscoveryRelations();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function scanAdminOpsAction() {
|
||
if (!isSuperAdmin()) throw new Error("只有平台管理者才能调用运维 Agent。");
|
||
setBusy(true, "运维 Agent 正在扫描故障事件...");
|
||
try {
|
||
const payload = await storyforgeFetch("/v2/admin/ops/incidents/scan", {
|
||
method: "POST",
|
||
body: {}
|
||
});
|
||
rememberAction("运维扫描已完成", `本轮共归集 ${formatNumber(payload.count)} 条故障事件。`, payload.count ? "orange" : "green", payload);
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
} finally {
|
||
setBusy(false, "");
|
||
renderAll();
|
||
}
|
||
}
|
||
|
||
function openAdminIncidentReviewAction(incidentId) {
|
||
if (!isSuperAdmin()) {
|
||
rememberAction("权限不足", "只有平台管理者才能审计处理故障事件。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
const incident = safeArray(appState.adminOpsOverview?.incidents).find((item) => item.id === incidentId);
|
||
if (!incident) {
|
||
rememberAction("故障事件不存在", "当前没有找到这条故障事件,请先重新扫描。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
openActionModal({
|
||
title: "审计处理故障事件",
|
||
description: "这里代表管理员侧审计 Agent 的放行/退回动作。",
|
||
submitLabel: "保存审计结果",
|
||
fields: [
|
||
{
|
||
name: "summary",
|
||
label: "事件摘要",
|
||
type: "html",
|
||
html: `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(incident.title)}</h4>
|
||
<p>${escapeHtml(incident.summary || "暂无摘要")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${incident.severity === "error" ? "red" : "orange"}">${escapeHtml(incident.severity || "warn")}</span>
|
||
<span class="tag">${escapeHtml(incident.status || "open")}</span>
|
||
${incident.source_type ? `<span class="tag">${escapeHtml(incident.source_type)}</span>` : ""}
|
||
${incident.tenant_user_id ? `<span class="tag">租户 ${escapeHtml(brief(incident.tenant_user_id, 12))}</span>` : ""}
|
||
</div>
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
${incident.source_type === "job" ? actionTag("看任务详情", "open-job-detail", `data-job-id="${escapeHtml(incident.source_id || "")}"`) : ""}
|
||
${incident.source_type === "integration" ? actionTag("去自动流程", "goto-automation") : ""}
|
||
${incident.tenant_project_id ? actionTag("去生产中心", "goto-production") : ""}
|
||
<span class="tag clickable-tag" data-action="scan-admin-ops">重新扫描</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
},
|
||
{ name: "status", label: "处理状态", type: "select", value: incident.status || "reviewed", options: [{ value: "reviewed", label: "已审阅" }, { value: "watching", label: "继续观察" }, { value: "resolved", label: "已解决" }, { value: "rejected", label: "驳回修复方案" }] },
|
||
{ name: "reviewNotes", label: "审计备注", type: "textarea", rows: 5, value: incident.review_notes || "", placeholder: "写清楚为什么放行、退回或继续观察" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/admin/ops/incidents/${encodeURIComponent(incident.id)}`, {
|
||
method: "PATCH",
|
||
body: {
|
||
status: values.status || "reviewed",
|
||
review_notes: values.reviewNotes || ""
|
||
}
|
||
});
|
||
rememberAction("审计结果已保存", `事件「${saved.title}」已更新为 ${saved.status}。`, "green", saved);
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openAdminRepairPlanAction(incidentId) {
|
||
if (!isSuperAdmin()) {
|
||
rememberAction("权限不足", "只有平台管理者才能生成修复计划。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
const incident = safeArray(appState.adminOpsOverview?.incidents).find((item) => item.id === incidentId);
|
||
if (!incident) {
|
||
rememberAction("故障事件不存在", "当前没有找到这条故障事件,请先重新扫描。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
openActionModal({
|
||
title: "生成修复计划",
|
||
description: "让运维 Agent 先生成一版 repair plan,再由审计 Agent 决定是否放行。",
|
||
submitLabel: "生成计划",
|
||
fields: [
|
||
{ name: "scope", label: "计划范围", type: "select", value: "plan", options: [{ value: "plan", label: "标准计划" }, { value: "hotfix", label: "热修建议" }, { value: "watch", label: "仅观察" }] },
|
||
{ name: "notes", label: "附加说明", type: "textarea", rows: 4, placeholder: "例如:优先验证 cutvideo 上传链,不要动核心代码" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/admin/ops/incidents/${encodeURIComponent(incidentId)}/repair-plan`, {
|
||
method: "POST",
|
||
body: {
|
||
incident_id: incidentId,
|
||
scope: values.scope || "plan",
|
||
notes: values.notes || ""
|
||
}
|
||
});
|
||
rememberAction("修复计划已生成", `已为事件「${incident.title}」生成 repair plan。`, "green", saved);
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openAdminFixRunDetailAction(runId) {
|
||
if (!isSuperAdmin()) {
|
||
rememberAction("权限不足", "只有平台管理者才能查看修复计划。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
const run = safeArray(appState.adminFixRuns.length ? appState.adminFixRuns : appState.adminOpsOverview?.recent_fix_runs).find((item) => item.id === runId);
|
||
if (!run) {
|
||
rememberAction("修复计划不存在", "当前没有找到这条修复计划,请先刷新管理员配置台。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
openActionModal({
|
||
title: "修复计划详情",
|
||
description: "查看这条修复计划的完整上下文,再决定是否放行。",
|
||
hideSubmit: true,
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "详情",
|
||
html: `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(run.plan?.summary || run.id)}</h4>
|
||
<p>${escapeHtml(safeArray(run.plan?.steps).join(";") || "暂无步骤")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag blue">${escapeHtml(run.plan_scope || "plan")}</span>
|
||
<span class="tag ${run.audit_status === "approved" ? "green" : run.audit_status === "rejected" ? "red" : "orange"}">${escapeHtml(run.audit_status || "pending")}</span>
|
||
${run.status ? `<span class="tag">${escapeHtml(run.status)}</span>` : ""}
|
||
${run.incident_id ? `<span class="tag">${escapeHtml(brief(run.incident_id, 12))}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="two-col" style="margin-top:12px;">
|
||
<div class="task-item compact">
|
||
<h4>Plan</h4>
|
||
<pre style="margin:0; white-space:pre-wrap; font-family:inherit; color:var(--muted); line-height:1.55;">${escapeHtml(JSON.stringify(run.plan || {}, null, 2))}</pre>
|
||
</div>
|
||
<div class="task-item compact">
|
||
<h4>Verification</h4>
|
||
<pre style="margin:0; white-space:pre-wrap; font-family:inherit; color:var(--muted); line-height:1.55;">${escapeHtml(JSON.stringify(run.verification || {}, null, 2))}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
]
|
||
});
|
||
}
|
||
|
||
function openAdminFixRunAuditAction(runId) {
|
||
if (!isSuperAdmin()) {
|
||
rememberAction("权限不足", "只有平台管理者才能审计修复计划。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
const run = safeArray(appState.adminFixRuns.length ? appState.adminFixRuns : appState.adminOpsOverview?.recent_fix_runs).find((item) => item.id === runId);
|
||
if (!run) {
|
||
rememberAction("修复计划不存在", "当前没有找到这条修复计划,请先刷新管理员配置台。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
openActionModal({
|
||
title: "审计修复计划",
|
||
description: "审计 Agent 只做放行、驳回或继续观察,不会直接让用户一句话改核心代码。",
|
||
submitLabel: "保存审计",
|
||
fields: [
|
||
{
|
||
name: "summary",
|
||
label: "计划摘要",
|
||
type: "html",
|
||
html: `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(run.plan?.summary || run.id)}</h4>
|
||
<p>${escapeHtml(safeArray(run.plan?.steps).join(";") || "暂无步骤")}</p>
|
||
<div class="task-meta" style="margin-top:10px;">
|
||
${run.incident_id ? `<span class="tag">事件 ${escapeHtml(brief(run.incident_id, 12))}</span>` : ""}
|
||
${run.updated_at ? `<span class="tag blue">${escapeHtml(formatDateTime(run.updated_at))}</span>` : ""}
|
||
<span class="tag clickable-tag" data-action="open-admin-fix-run-detail" data-run-id="${escapeHtml(run.id)}">查看详情</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
},
|
||
{ name: "reviewStatus", label: "审计状态", type: "select", value: run.audit_status || "approved", options: [{ value: "approved", label: "通过" }, { value: "watching", label: "继续观察" }, { value: "rejected", label: "驳回" }] },
|
||
{ name: "reviewNotes", label: "审计备注", type: "textarea", rows: 4, value: run.review_notes || "", placeholder: "写清楚为什么通过、驳回或继续观察" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/admin/ops/fix-runs/${encodeURIComponent(runId)}/audit`, {
|
||
method: "POST",
|
||
body: {
|
||
review_status: values.reviewStatus || "approved",
|
||
review_notes: values.reviewNotes || ""
|
||
}
|
||
});
|
||
rememberAction("修复计划已审计", `修复计划 ${runId} 已更新为 ${saved.audit_status || values.reviewStatus}。`, "green", saved);
|
||
await loadAgentControlSurfaces(getOneLinerProjectId());
|
||
renderAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openJobDetailAction(jobId) {
|
||
if (!jobId) return;
|
||
setBusy(true, "正在加载任务详情...");
|
||
loadJobDetail(jobId)
|
||
.then(({ job, events, childJobs }) => {
|
||
const artifacts = JSON.stringify(job.artifacts || {}, null, 2);
|
||
const result = JSON.stringify(job.result || {}, null, 2);
|
||
const previewLinks = getJobPreviewLinks(job);
|
||
const recovery = getJobRecoverability(job);
|
||
openActionModal({
|
||
title: job.title || "任务详情",
|
||
description: `状态:${job.status || "-"} · 类型:${job.line_type || job.source_type || "-"}`,
|
||
hideSubmit: true,
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "任务摘要",
|
||
html: `
|
||
<div class="detail-grid">
|
||
<div class="mini-card"><small>任务 ID</small><strong>${escapeHtml(job.id)}</strong></div>
|
||
<div class="mini-card"><small>状态</small><strong>${escapeHtml(job.status || "-")}</strong></div>
|
||
<div class="mini-card"><small>链路</small><strong>${escapeHtml(job.line_type || "-")}</strong></div>
|
||
<div class="mini-card"><small>创建时间</small><strong>${escapeHtml(formatDateTime(job.created_at))}</strong></div>
|
||
</div>
|
||
`
|
||
},
|
||
{
|
||
type: "html",
|
||
label: "事件时间线",
|
||
html: `
|
||
<div class="list">
|
||
${safeArray(events).slice(-6).map((event) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(event.event_type || "event")}</h4>
|
||
<p>${escapeHtml(brief(event.message || JSON.stringify(event.payload || {}), 120))}</p>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>暂无事件</h4><p>当前任务还没有可显示的事件。</p></div>`}
|
||
</div>
|
||
`
|
||
},
|
||
{
|
||
type: "html",
|
||
label: "结果预览",
|
||
html: `
|
||
<div class="list">
|
||
${previewLinks.map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.label.replace(/^result\./, "").replace(/^artifacts\./, ""))}</h4>
|
||
<p>${escapeHtml(item.url)}</p>
|
||
<div class="task-meta">
|
||
<a class="tag" href="${escapeHtml(item.url)}" target="_blank" rel="noreferrer">打开结果</a>
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>暂无外部结果链接</h4><p>当前先保留 artifacts 和 result 原始数据供查看。</p></div>`}
|
||
</div>
|
||
`
|
||
},
|
||
{
|
||
type: "html",
|
||
label: "下一步动作",
|
||
html: `
|
||
<div class="task-meta">
|
||
${job.status === "failed" ? actionTag(
|
||
recovery.actionLabel,
|
||
recovery.recoverable ? "recover-job" : recovery.actionKey,
|
||
`data-job-id="${escapeHtml(job.id)}"`,
|
||
{ disabledReason: recovery.recoverable ? "" : recovery.reason, title: recovery.reason }
|
||
) : ""}
|
||
${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "继续做 AI 视频") : ""}
|
||
${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "继续做实拍剪辑") : ""}
|
||
${actionTag("用摘要写文案", "job-to-generate-copy", `data-job-id="${escapeHtml(job.id)}"`)}
|
||
</div>
|
||
`
|
||
},
|
||
{
|
||
type: "html",
|
||
label: "恢复判断",
|
||
html: `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(recovery.label)}</h4>
|
||
<p>${escapeHtml(recovery.reason)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${recovery.recoverable ? "green" : recovery.state === "blocked" ? "red" : "orange"}">${escapeHtml(recovery.actionLabel)}</span>
|
||
${recovery.sourceJobId ? `<span class="tag">源任务 ${escapeHtml(brief(recovery.sourceJobId, 12))}</span>` : ""}
|
||
${recovery.quotaGuard?.blocked ? `<span class="tag red">额度拦截</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`
|
||
},
|
||
{
|
||
type: "html",
|
||
label: "子任务",
|
||
html: `
|
||
<div class="list">
|
||
${safeArray(childJobs).slice(0, 6).map((item) => `
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(item.title || item.id)}</h4>
|
||
<p>${escapeHtml(brief(item.style_summary || item.transcript_text || item.error || "暂无摘要", 96))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${statusTone(item.status)}">${escapeHtml(item.status || "-")}</span>
|
||
<span class="tag">${escapeHtml(item.line_type || "-")}</span>
|
||
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(item.id)}">看详情</span>
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="task-item compact"><h4>暂无子任务</h4><p>当前任务还没有派生出下一层任务。</p></div>`}
|
||
</div>
|
||
`
|
||
},
|
||
{ type: "textarea", name: "artifactsReadonly", label: "Artifacts", value: artifacts, rows: 8 },
|
||
{ type: "textarea", name: "resultReadonly", label: "Result", value: result, rows: 8 }
|
||
]
|
||
});
|
||
document.querySelector('[data-action-field="artifactsReadonly"]')?.setAttribute("readonly", "readonly");
|
||
document.querySelector('[data-action-field="resultReadonly"]')?.setAttribute("readonly", "readonly");
|
||
})
|
||
.catch((error) => {
|
||
presentActionFailure(error, "加载任务详情失败");
|
||
})
|
||
.finally(() => {
|
||
setBusy(false, "");
|
||
});
|
||
}
|
||
|
||
function openRecoverJobAction(jobId) {
|
||
const job = safeArray(appState.dashboard?.recent_jobs).find((item) => item.id === jobId)
|
||
|| (appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null);
|
||
if (!job) {
|
||
rememberAction("任务不存在", "当前没有找到这条任务,请先刷新生产中心。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
const recovery = getJobRecoverability(job);
|
||
if (!recovery.recoverable) {
|
||
presentActionFailure(new Error(recovery.reason || "当前任务暂不支持恢复"), "当前任务暂不可恢复");
|
||
return;
|
||
}
|
||
openActionModal({
|
||
title: "恢复失败任务",
|
||
description: recovery.reason,
|
||
submitLabel: recovery.actionLabel,
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "恢复摘要",
|
||
html: `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(job.title || job.id)}</h4>
|
||
<p>${escapeHtml(brief(job.error || recovery.reason || "任务失败,可重新发起。", 120))}</p>
|
||
<div class="task-meta">
|
||
<span class="tag ${recovery.recoverable ? "green" : recovery.state === "blocked" ? "red" : "orange"}">${escapeHtml(recovery.label)}</span>
|
||
<span class="tag">${escapeHtml(job.line_type || job.source_type || "analysis")}</span>
|
||
${recovery.sourceJobId ? `<span class="tag">源任务 ${escapeHtml(brief(recovery.sourceJobId, 12))}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
${recovery.quotaGuard?.blocked ? `
|
||
<div class="task-item compact">
|
||
<h4>额度提醒</h4>
|
||
<p>${escapeHtml(recovery.quotaGuard.reason)}</p>
|
||
</div>
|
||
` : ""}
|
||
</div>
|
||
`
|
||
},
|
||
{ name: "note", label: "恢复备注", type: "textarea", rows: 4, value: "", placeholder: "可写明为什么需要恢复,或补充额外背景" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
setBusy(true, "正在恢复任务...");
|
||
try {
|
||
const result = await recoverJobAction(job.id, { mode: "single", job, user_feedback: values.note?.trim() || "" });
|
||
rememberAction("任务已恢复", `${job.title || job.id} 已重新发起,下一步可以去生产中心继续跟进。`, "green", result);
|
||
await bootstrap();
|
||
if (result?.created?.id) {
|
||
openJobDetailAction(result.created.id);
|
||
} else {
|
||
focusProductionDetailTab("recovery");
|
||
}
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function openBatchRecoverJobsAction() {
|
||
const failedJobs = getRecoverableFailedJobs();
|
||
const selectable = failedJobs.filter((item) => item.recovery.recoverable);
|
||
if (!selectable.length) {
|
||
rememberAction("没有可恢复任务", "当前没有可直接恢复的失败任务。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
openActionModal({
|
||
title: "批量恢复失败任务",
|
||
description: "只会选中当前可自动恢复的失败任务;需要人工补素材或补额度的任务会保留在列表里。",
|
||
submitLabel: "批量恢复",
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "批量摘要",
|
||
html: `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>可恢复 ${escapeHtml(formatNumber(selectable.length))} 条</h4>
|
||
<p>${escapeHtml(`最近失败任务共 ${formatNumber(failedJobs.length)} 条,其中 ${formatNumber(failedJobs.length - selectable.length)} 条需要人工处理。`)}</p>
|
||
<div class="task-meta">
|
||
<span class="tag green">${escapeHtml(formatNumber(selectable.length))} 可恢复</span>
|
||
<span class="tag orange">${escapeHtml(formatNumber(failedJobs.length - selectable.length))} 需人工</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
},
|
||
...selectable.map(({ job, recovery }, index) => ({
|
||
name: `recoverJob_${index}`,
|
||
label: `${job.title || job.id} · ${recovery.reason}`,
|
||
type: "checkbox",
|
||
value: true
|
||
})),
|
||
{ name: "note", label: "批量恢复备注", type: "textarea", rows: 4, value: "", placeholder: "可写明这次批量恢复的背景或处理策略" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const chosen = selectable.filter((item, index) => values[`recoverJob_${index}`]).map((item) => item.job);
|
||
if (!chosen.length) throw new Error("请至少选择一个可恢复任务");
|
||
setBusy(true, "正在批量恢复任务...");
|
||
const successes = [];
|
||
const failures = [];
|
||
try {
|
||
for (const job of chosen) {
|
||
try {
|
||
const result = await recoverJobAction(job.id, { mode: "batch", job });
|
||
successes.push({ job, result });
|
||
} catch (error) {
|
||
failures.push({ job, error: formatActionErrorMessage(error, "恢复失败") });
|
||
}
|
||
}
|
||
rememberAction(
|
||
"批量恢复已完成",
|
||
failures.length
|
||
? `已恢复 ${successes.length} 条,另有 ${failures.length} 条失败。`
|
||
: `已恢复 ${successes.length} 条失败任务。`,
|
||
failures.length ? "orange" : "green",
|
||
{ successes, failures }
|
||
);
|
||
if (values.note?.trim()) {
|
||
recordRecoveryEvent({
|
||
id: `batch_${Date.now()}`,
|
||
account_id: appState.session?.account?.id || "",
|
||
project_id: appState.selectedProjectId || "",
|
||
job_title: "批量恢复",
|
||
job_line_type: "batch",
|
||
job_source_type: "production",
|
||
job_status: "completed",
|
||
action_key: "batch-recover-jobs",
|
||
mode: "batch",
|
||
summary: `批量恢复 ${successes.length} 条任务`,
|
||
reason: values.note.trim(),
|
||
result_label: failures.length ? "部分成功" : "全部成功",
|
||
result_reason: failures.length ? `失败 ${failures.length} 条` : "全部成功"
|
||
});
|
||
}
|
||
await bootstrap();
|
||
if (successes[0]?.result?.created?.id) {
|
||
openJobDetailAction(successes[0].result.created.id);
|
||
} else {
|
||
focusProductionDetailTab("recovery");
|
||
}
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function openGenerateCopyAction(defaults = {}) {
|
||
const assistant = getSelectedAssistant() || requireSelectedAssistant();
|
||
const sourceJob = defaults.sourceJob || null;
|
||
openActionModal({
|
||
title: "生成文案",
|
||
description: "用当前 Agent 和知识库生成一版短视频文案。",
|
||
submitLabel: "开始生成",
|
||
fields: [
|
||
{ name: "brief", label: "创作需求", type: "textarea", rows: 5, value: defaults.brief || getJobSeedBrief(sourceJob), placeholder: "例如:给创业者写一条 60 字内的短视频开场文案" },
|
||
{ name: "platform", label: "平台", type: "select", value: normalizePlatformValue(defaults.platform || "douyin"), options: getPlatformOptions() },
|
||
{ name: "audience", label: "受众", value: "创业者" },
|
||
{ name: "extraRequirements", label: "额外要求", placeholder: "例如:强结论开头,结尾带 CTA" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.brief?.trim()) throw new Error("请填写创作需求");
|
||
const result = await storyforgeFetch(`/v2/assistants/${encodeURIComponent(assistant.id)}/generate`, {
|
||
method: "POST",
|
||
body: {
|
||
brief: values.brief.trim(),
|
||
platform: platformLabel(values.platform || "douyin"),
|
||
audience: values.audience || "创业者",
|
||
extra_requirements: values.extraRequirements || "",
|
||
knowledge_base_ids: safeArray(assistant.knowledge_base_ids)
|
||
}
|
||
});
|
||
appState.lastGeneratedCopy = {
|
||
assistantId: assistant.id,
|
||
assistantName: assistant.name,
|
||
prompt: values.brief.trim(),
|
||
content: extractGeneratedCopy(result),
|
||
usedDocuments: safeArray(result.used_documents).slice(0, 3)
|
||
};
|
||
rememberAction("文案生成完成", `已用 Agent「${assistant.name}」生成一版文案。`, "green", result);
|
||
focusRecentGeneratedCopy();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openCreateAiVideoAction(defaults = {}) {
|
||
const guard = getPipelineGuard("aiVideo");
|
||
if (!guard.enabled) {
|
||
presentActionFailure(new Error(guard.reason), "AI 视频暂不可用");
|
||
return;
|
||
}
|
||
const project = requireSelectedProject();
|
||
const assistant = getSelectedAssistant();
|
||
const kb = getProjectKnowledgeBases(project.id)[0];
|
||
const sourceJob = defaults.sourceJob || null;
|
||
openActionModal({
|
||
title: "创建 AI 视频任务",
|
||
description: "输入 brief 后,直接触发 AI 视频链。",
|
||
submitLabel: "开始生产",
|
||
fields: [
|
||
{ name: "title", label: "任务标题", value: defaults.title || (sourceJob ? `${sourceJob.title} · AI 视频` : ""), placeholder: "例如:创业口播 AI 视频测试" },
|
||
{ name: "brief", label: "视频 brief", type: "textarea", rows: 5, value: defaults.brief || getJobSeedBrief(sourceJob), placeholder: "写明主题、风格、镜头和目标受众" },
|
||
{ name: "sourceJobId", label: "关联源任务", type: "select", value: defaults.sourceJobId || sourceJob?.id || "", options: [{ value: "", label: "不关联" }, ...getCompletedJobOptions()] },
|
||
{ name: "style", label: "风格", value: defaults.style || "realistic" },
|
||
{ name: "shots", label: "镜头数", type: "number", value: defaults.shots || 4, min: 1, max: 12 },
|
||
{ name: "duration", label: "单镜头秒数", type: "number", value: defaults.duration || 5, min: 3, max: 12 }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.title?.trim()) throw new Error("请填写任务标题");
|
||
if (!values.brief?.trim()) throw new Error("请填写视频 brief");
|
||
const job = await storyforgeFetch("/v2/pipelines/ai-video", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: project.id,
|
||
assistant_id: assistant?.id || "",
|
||
knowledge_base_id: kb?.id || "",
|
||
source_job_id: values.sourceJobId || "",
|
||
title: values.title.trim(),
|
||
brief: values.brief.trim(),
|
||
style: values.style || "realistic",
|
||
shots: Number(values.shots || 4),
|
||
duration: Number(values.duration || 5)
|
||
}
|
||
});
|
||
rememberAction("AI 视频任务已创建", `已创建任务 ${job.title || job.id}。`, "blue", job);
|
||
await bootstrap();
|
||
if (job?.id) {
|
||
openJobDetailAction(job.id);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function openCreateRealCutAction(defaults = {}) {
|
||
const guard = getPipelineGuard("realCut");
|
||
if (!guard.enabled) {
|
||
presentActionFailure(new Error(guard.reason), "实拍剪辑暂不可用");
|
||
return;
|
||
}
|
||
const project = requireSelectedProject();
|
||
const sourceJob = defaults.sourceJob || null;
|
||
openActionModal({
|
||
title: "创建实拍剪辑任务",
|
||
description: "基于已完成的源任务,把素材发到 cutvideo。",
|
||
submitLabel: "开始剪辑",
|
||
fields: [
|
||
{ name: "title", label: "任务标题", value: defaults.title || (sourceJob ? `${sourceJob.title} · 实拍剪辑` : ""), placeholder: "例如:创业素材粗剪" },
|
||
{ name: "sourceJobId", label: "源任务", type: "select", value: defaults.sourceJobId || sourceJob?.id || getCompletedJobOptions()[0]?.value || "", options: getCompletedJobOptions() },
|
||
{ name: "targetDurationSec", label: "目标时长(秒)", type: "number", value: defaults.targetDurationSec || 60, min: 10, max: 300 },
|
||
{ name: "aspectRatio", label: "画幅", value: defaults.aspectRatio || "9:16" },
|
||
{ name: "objective", label: "目标", type: "textarea", rows: 4, value: defaults.objective || "", placeholder: "例如:保留高信息密度片段,输出适合短视频平台的粗剪结果" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.title?.trim()) throw new Error("请填写任务标题");
|
||
if (!values.sourceJobId) throw new Error("请先选择一个已完成的源任务");
|
||
const job = await storyforgeFetch("/v2/pipelines/real-cut", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: project.id,
|
||
title: values.title.trim(),
|
||
source_job_id: values.sourceJobId,
|
||
target_duration_sec: Number(values.targetDurationSec || 60),
|
||
target_aspect_ratio: values.aspectRatio || "9:16",
|
||
objective: values.objective || "保留高信息密度片段,输出适合短视频平台的粗剪结果"
|
||
}
|
||
});
|
||
rememberAction("实拍剪辑任务已创建", `已创建任务 ${job.title || job.id}。`, "blue", job);
|
||
await bootstrap();
|
||
if (job?.id) {
|
||
openJobDetailAction(job.id);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function openLiveRecorderAction() {
|
||
setScreen("production");
|
||
renderAll();
|
||
window.requestAnimationFrame(() => {
|
||
document.getElementById("live-recorder-maintenance-anchor")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
}
|
||
|
||
function openLiveRecorderCreateAction() {
|
||
const status = getIntegrationDetail("live_recorder");
|
||
const project = getSelectedProject() || appState.dashboard?.projects?.[0] || null;
|
||
const assistants = getAssistantOptions(project?.id || "");
|
||
openActionModal({
|
||
title: "新增录制源",
|
||
description: status.reachable
|
||
? "新增的是你当前租户名下的录制源。文件访问和录制状态也只会回到你的账号视图里。"
|
||
: "当前 NAS 录制服务不可达,先检查集成健康。",
|
||
submitLabel: "保存录制源",
|
||
fields: [
|
||
{ type: "html", label: "当前租户", html: renderLiveRecorderSummaryHtml() },
|
||
{ name: "projectId", label: "归属项目", type: "select", value: project?.id || "", options: getProjectOptions() },
|
||
{ name: "assistantId", label: "关联 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
|
||
{ name: "platform", label: "平台", type: "select", value: "kuaishou", options: getPlatformOptions() },
|
||
{ name: "title", label: "录制名称", placeholder: "例如:A 类目直播跟踪" },
|
||
{ name: "quality", label: "清晰度", type: "select", value: "原画", options: ["原画", "蓝光", "超清", "高清", "标清", "流畅"].map((item) => ({ value: item, label: item })) },
|
||
{ name: "sourceUrl", label: "直播源", type: "url", placeholder: "https://..." },
|
||
{ name: "autoStart", label: "导入后立即开始", type: "checkbox", value: true }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.sourceUrl?.trim()) throw new Error("请填写直播源链接");
|
||
const saved = await storyforgeFetch("/v2/live-recorder/sources", {
|
||
method: "POST",
|
||
body: {
|
||
project_id: values.projectId || project?.id || "",
|
||
assistant_id: values.assistantId || "",
|
||
platform: normalizePlatformValue(values.platform, "kuaishou"),
|
||
source_url: values.sourceUrl.trim(),
|
||
title: values.title || "",
|
||
quality: values.quality || "原画",
|
||
enabled: true
|
||
}
|
||
});
|
||
let started = null;
|
||
if (values.autoStart) {
|
||
try {
|
||
started = await storyforgeFetch("/v2/live-recorder/recorder/start", { method: "POST", body: {} });
|
||
} catch (error) {
|
||
started = { ok: false, message: error.message };
|
||
}
|
||
}
|
||
rememberAction("直播录制已下发", "当前租户的直播源已经保存到服务端并同步到 NAS。", "green", { saved, started });
|
||
await bootstrap();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openLiveRecorderSourceAction(sourceId) {
|
||
const source = safeArray(appState.liveRecorderSources).find((item) => item.id === sourceId);
|
||
if (!source) {
|
||
rememberAction("录制源不存在", "当前没有找到这条录制源,请先刷新录制维护。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
const currentProject = getSelectedProject() || safeArray(appState.dashboard?.projects).find((item) => item.id === source.project_id) || appState.dashboard?.projects?.[0] || null;
|
||
const assistants = getAssistantOptions(currentProject?.id || source.project_id || "");
|
||
openActionModal({
|
||
title: "编辑录制源",
|
||
description: "可以更新项目归属、Agent、标题、清晰度和启停状态;链接本身若要变更,请删除后重建。",
|
||
submitLabel: "保存修改",
|
||
fields: [
|
||
{
|
||
type: "html",
|
||
label: "源信息",
|
||
html: `
|
||
<div class="sheet-html">
|
||
<div class="task-item compact">
|
||
<h4>${escapeHtml(source.title || source.remote_name || "录制源")}</h4>
|
||
<p>${escapeHtml(source.source_url || "暂无链接")}</p>
|
||
<div class="task-meta">
|
||
<span class="tag">${escapeHtml(platformLabel(source.platform || "kuaishou"))}</span>
|
||
<span class="tag">${escapeHtml(source.quality || "原画")}</span>
|
||
<span class="tag ${source.enabled ? "green" : "orange"}">${escapeHtml(source.enabled ? "启用" : "停用")}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
},
|
||
{ name: "projectId", label: "归属项目", type: "select", value: source.project_id || currentProject?.id || "", options: getProjectOptions() },
|
||
{ name: "assistantId", label: "关联 Agent", type: "select", value: source.assistant_id || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] },
|
||
{ name: "title", label: "录制名称", value: source.title || "", placeholder: "例如:A 类目直播跟踪" },
|
||
{ name: "quality", label: "清晰度", type: "select", value: source.quality || "原画", options: ["原画", "蓝光", "超清", "高清", "标清", "流畅"].map((item) => ({ value: item, label: item })) },
|
||
{ name: "enabled", label: "启用录制源", type: "checkbox", value: Boolean(source.enabled) }
|
||
],
|
||
onSubmit: async (values) => {
|
||
const saved = await storyforgeFetch(`/v2/live-recorder/sources/${encodeURIComponent(source.id)}`, {
|
||
method: "PATCH",
|
||
body: {
|
||
project_id: values.projectId || "",
|
||
assistant_id: values.assistantId || "",
|
||
title: values.title || "",
|
||
quality: values.quality || "原画",
|
||
enabled: Boolean(values.enabled)
|
||
}
|
||
});
|
||
rememberAction("录制源已更新", `已保存「${saved.item?.title || source.title || "录制源"}」。`, "green", saved);
|
||
await bootstrap();
|
||
}
|
||
});
|
||
}
|
||
|
||
function openLiveRecorderImportAction() {
|
||
const samples = [
|
||
"https://live.douyin.com/1234567890",
|
||
"# 关闭的源会以 # 开头",
|
||
"高清, https://live.kuaishou.com/u/abcdef, 测试录制源"
|
||
].join("\n");
|
||
openActionModal({
|
||
title: "导入 URL 配置",
|
||
description: "按行粘贴直播源,支持用逗号附带清晰度和标题,注释行会被视为停用源。",
|
||
submitLabel: "导入并同步",
|
||
fields: [
|
||
{
|
||
name: "raw",
|
||
label: "配置文本",
|
||
type: "textarea",
|
||
rows: 10,
|
||
value: samples,
|
||
placeholder: "一行一个 URL,支持 # 注释和 逗号分隔的清晰度/标题"
|
||
}
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!String(values.raw || "").trim()) throw new Error("请先粘贴配置文本");
|
||
const saved = await storyforgeFetch("/v2/live-recorder/url-config/import", {
|
||
method: "POST",
|
||
body: { raw: values.raw }
|
||
});
|
||
rememberAction("URL 配置已导入", `已导入 ${formatNumber(saved.count || 0)} 条录制源。`, "green", saved);
|
||
await bootstrap();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function toggleLiveRecorderSourceAction(sourceId, nextEnabled) {
|
||
const source = safeArray(appState.liveRecorderSources).find((item) => item.id === sourceId);
|
||
if (!source) {
|
||
rememberAction("录制源不存在", "当前没有找到这条录制源,请先刷新录制维护。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
setBusy(true, nextEnabled ? "正在启用录制源..." : "正在停用录制源...");
|
||
try {
|
||
await storyforgeFetch(`/v2/live-recorder/sources/${encodeURIComponent(source.id)}`, {
|
||
method: "PATCH",
|
||
body: {
|
||
enabled: Boolean(nextEnabled)
|
||
}
|
||
});
|
||
rememberAction(nextEnabled ? "录制源已启用" : "录制源已停用", `${source.title || source.source_url || "录制源"} 已更新。`, "green");
|
||
await bootstrap();
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
|
||
async function deleteLiveRecorderSourceAction(sourceId) {
|
||
const source = safeArray(appState.liveRecorderSources).find((item) => item.id === sourceId);
|
||
if (!source) {
|
||
rememberAction("录制源不存在", "当前没有找到这条录制源,请先刷新录制维护。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
openActionModal({
|
||
title: "确认删除录制源",
|
||
description: `确认删除「${source.title || source.source_url || "录制源"}」吗?删除后需要重新导入。`,
|
||
submitLabel: "确认删除",
|
||
onSubmit: async () => {
|
||
setBusy(true, "正在删除录制源...");
|
||
try {
|
||
await storyforgeFetch(`/v2/live-recorder/sources/${encodeURIComponent(source.id)}`, {
|
||
method: "DELETE"
|
||
});
|
||
rememberAction("录制源已删除", `${source.title || source.source_url || "录制源"} 已从租户视图中移除。`, "green");
|
||
await bootstrap();
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function openLiveRecorderFileAction(fileId) {
|
||
const target = safeArray(appState.liveRecorderFiles).find((item) => item.id === fileId);
|
||
if (!target?.content_url) {
|
||
throw new Error("当前录像文件不存在,可能已经被移除");
|
||
}
|
||
const blob = await storyforgeFetchBlob(target.content_url);
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
|
||
}
|
||
|
||
async function openStorageArtifactAction(fileId) {
|
||
const usage = appState.storageStatus?.tenant_usage || {};
|
||
const candidates = [
|
||
...safeArray(usage.recent_download_artifacts),
|
||
...safeArray(usage.recent_job_artifacts),
|
||
...safeArray(appState.storageStatus?.recent_files),
|
||
...safeArray(appState.storageStatus?.recent_artifacts)
|
||
];
|
||
const target = candidates.find((item) => item.id === fileId);
|
||
if (!target?.content_url) {
|
||
throw new Error("当前产物不存在,可能已经被清理");
|
||
}
|
||
const blob = await storyforgeFetchBlob(target.content_url);
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
|
||
}
|
||
|
||
function openReviewAction(defaults = {}) {
|
||
const project = requireSelectedProject();
|
||
const assistants = getAssistantOptions(project.id);
|
||
const sourceJob = defaults.sourceJob || null;
|
||
const existingReview = defaults.review || null;
|
||
const metrics = existingReview?.metrics || {};
|
||
openActionModal({
|
||
title: existingReview ? "编辑复盘" : "写复盘",
|
||
description: existingReview
|
||
? "补充表现数据、判断和下一步动作,持续迭代项目策略。"
|
||
: "把完成任务写成一条可追踪复盘,后续可按项目累计。",
|
||
submitLabel: existingReview ? "保存复盘" : "创建复盘",
|
||
fields: [
|
||
{ name: "title", label: "标题", value: existingReview?.title || defaults.title || sourceJob?.title || "", placeholder: "例如:创业口播 3 月 22 日复盘" },
|
||
{ name: "sourceJobId", label: "关联任务", type: "select", value: existingReview?.source_job_id || defaults.sourceJobId || sourceJob?.id || "", options: [{ value: "", label: "不关联任务" }, ...getCompletedJobOptions()] },
|
||
{ name: "assistantId", label: "负责 Agent", type: "select", value: existingReview?.assistant_id || getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] },
|
||
{ name: "platform", label: "平台", type: "select", value: normalizePlatformValue(existingReview?.platform || defaults.platform || "douyin"), options: getPlatformOptions() },
|
||
{ name: "contentType", label: "内容类型", type: "select", value: existingReview?.content_type || "video", options: [
|
||
{ value: "video", label: "视频" },
|
||
{ value: "image_text", label: "图文" },
|
||
{ value: "live_clip", label: "直播切片" }
|
||
] },
|
||
{ name: "publishUrl", label: "发布链接", type: "url", value: existingReview?.publish_url || "", placeholder: "https://..." },
|
||
{ name: "publishedAt", label: "发布时间", value: existingReview?.published_at || "", placeholder: "2026-03-22T20:00:00+08:00" },
|
||
{ name: "playCount", label: "播放", type: "number", value: metrics.play_count || 0, min: 0 },
|
||
{ name: "likeCount", label: "点赞", type: "number", value: metrics.like_count || 0, min: 0 },
|
||
{ name: "commentCount", label: "评论", type: "number", value: metrics.comment_count || 0, min: 0 },
|
||
{ name: "shareCount", label: "分享", type: "number", value: metrics.share_count || 0, min: 0 },
|
||
{ name: "verdict", label: "结论", type: "select", value: existingReview?.verdict || "", options: [
|
||
{ value: "", label: "先不下结论" },
|
||
{ value: "worth_scaling", label: "值得放大" },
|
||
{ value: "needs_rework", label: "需要重做" },
|
||
{ value: "good_reference", label: "适合借鉴" },
|
||
{ value: "hold", label: "先观察" }
|
||
] },
|
||
{ name: "highlights", label: "亮点", type: "textarea", rows: 4, value: existingReview?.highlights || "", placeholder: "例如:开头 3 秒抓人、评论区问题很集中" },
|
||
{ name: "nextActions", label: "下一步", type: "textarea", rows: 4, value: existingReview?.next_actions || "", placeholder: "例如:保留结构,换一个细分人群再做一条" },
|
||
{ name: "notes", label: "备注", type: "textarea", rows: 4, value: existingReview?.notes || "", placeholder: "补充团队讨论、平台环境、发布时间段等信息" }
|
||
],
|
||
onSubmit: async (values) => {
|
||
if (!values.title?.trim()) throw new Error("请填写复盘标题");
|
||
const payload = {
|
||
project_id: project.id,
|
||
source_job_id: values.sourceJobId || "",
|
||
assistant_id: values.assistantId || "",
|
||
title: values.title.trim(),
|
||
platform: normalizePlatformValue(values.platform, "douyin"),
|
||
content_type: values.contentType || "video",
|
||
publish_url: values.publishUrl || "",
|
||
published_at: values.publishedAt || "",
|
||
metrics: {
|
||
play_count: Number(values.playCount || 0),
|
||
like_count: Number(values.likeCount || 0),
|
||
comment_count: Number(values.commentCount || 0),
|
||
share_count: Number(values.shareCount || 0)
|
||
},
|
||
verdict: values.verdict || "",
|
||
highlights: values.highlights || "",
|
||
next_actions: values.nextActions || "",
|
||
notes: values.notes || ""
|
||
};
|
||
const review = existingReview
|
||
? await storyforgeFetch(`/v2/reviews/${encodeURIComponent(existingReview.id)}`, {
|
||
method: "PATCH",
|
||
body: payload
|
||
})
|
||
: await storyforgeFetch("/v2/reviews", {
|
||
method: "POST",
|
||
body: payload
|
||
});
|
||
rememberAction(existingReview ? "复盘已更新" : "复盘已创建", `已保存「${review.title}」并回写到项目复盘。`, "green", review);
|
||
await bootstrap();
|
||
focusReviewWorkspace(review.id);
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener("click", async (event) => {
|
||
if (event.target instanceof HTMLElement && event.target.classList.contains("oneliner-backdrop")) {
|
||
closeOneLinerPanel();
|
||
return;
|
||
}
|
||
const action = event.target.closest("[data-action]");
|
||
if (action) {
|
||
const name = action.dataset.action;
|
||
if (name === "open-mobile-sidebar") {
|
||
setMobileSidebarOpen(true);
|
||
return;
|
||
}
|
||
if (name === "close-mobile-sidebar") {
|
||
if (action.closest(".sidebar") || action.classList.contains("mobile-sidebar-backdrop")) {
|
||
setMobileSidebarOpen(false);
|
||
} else {
|
||
setMobileSidebarOpen(false);
|
||
}
|
||
return;
|
||
}
|
||
if (name === "open-auth") {
|
||
openAuthModal();
|
||
return;
|
||
}
|
||
if (name === "close-auth") {
|
||
closeAuthModal();
|
||
return;
|
||
}
|
||
if (name === "open-oneliner") {
|
||
try {
|
||
setBusy(true, "正在打开 OneLiner...");
|
||
if (appState.session) {
|
||
await loadAgentControlSurfaces(appState.selectedProjectId || "");
|
||
if (appState.selectedOnelinerSessionId) {
|
||
await loadOneLinerMessages(appState.selectedOnelinerSessionId);
|
||
} else {
|
||
await ensureOneLinerSession();
|
||
}
|
||
}
|
||
openOneLinerPanel();
|
||
renderAll();
|
||
} finally {
|
||
setBusy(false, "");
|
||
renderAll();
|
||
}
|
||
return;
|
||
}
|
||
if (name === "close-oneliner") {
|
||
closeOneLinerPanel();
|
||
return;
|
||
}
|
||
if (name === "close-sheet") {
|
||
closeActionModal();
|
||
return;
|
||
}
|
||
if (name === "submit-sheet") {
|
||
await submitActionModal();
|
||
return;
|
||
}
|
||
if (name === "submit-auth") {
|
||
setBusy(true, "正在自动连接并加载...");
|
||
try {
|
||
const message = document.querySelector('[data-role="auth-message"]');
|
||
if (message) message.textContent = "";
|
||
appState.autoConnectSuppressed = false;
|
||
appState.autoConnectAttempted = false;
|
||
await ensureAutoSession({ force: true });
|
||
closeAuthModal();
|
||
await bootstrap();
|
||
} catch (error) {
|
||
const message = document.querySelector('[data-role="auth-message"]');
|
||
if (message) message.textContent = formatActionErrorMessage(error, "自动连接失败");
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "show-disabled-reason") {
|
||
const reason = action.dataset.disabledReason || action.title || "当前动作暂不可用";
|
||
rememberAction("动作已拦截", reason, "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
if (name === "auth-refresh" || name === "refresh-data") {
|
||
setBusy(true, name === "auth-refresh" ? "正在重新自动连接..." : "正在刷新数据...");
|
||
try {
|
||
if (name === "auth-refresh") {
|
||
const message = document.querySelector('[data-role="auth-message"]');
|
||
if (message) message.textContent = "";
|
||
await refreshFromAuthModal();
|
||
} else {
|
||
await bootstrap();
|
||
}
|
||
} catch (error) {
|
||
const message = document.querySelector('[data-role="auth-message"]');
|
||
if (name === "auth-refresh" && message) {
|
||
message.textContent = formatActionErrorMessage(error, "自动连接失败");
|
||
} else {
|
||
presentActionFailure(error, "刷新数据失败");
|
||
}
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "refresh-tracking") {
|
||
try {
|
||
await refreshTrackingAccountsAction();
|
||
} catch (error) {
|
||
presentActionFailure(error, "刷新跟踪账号失败");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "open-live-recorder") {
|
||
openLiveRecorderAction();
|
||
return;
|
||
}
|
||
if (name === "open-live-recorder-create") {
|
||
openLiveRecorderCreateAction();
|
||
return;
|
||
}
|
||
if (name === "import-live-recorder-config") {
|
||
openLiveRecorderImportAction();
|
||
return;
|
||
}
|
||
if (name === "edit-live-recorder-source") {
|
||
openLiveRecorderSourceAction(action.dataset.sourceId || "");
|
||
return;
|
||
}
|
||
if (name === "toggle-live-recorder-source") {
|
||
await toggleLiveRecorderSourceAction(action.dataset.sourceId || "", action.dataset.nextEnabled === "true");
|
||
return;
|
||
}
|
||
if (name === "delete-live-recorder-source") {
|
||
await deleteLiveRecorderSourceAction(action.dataset.sourceId || "");
|
||
return;
|
||
}
|
||
if (name === "open-live-recorder-file") {
|
||
await openLiveRecorderFileAction(action.dataset.fileId || "");
|
||
return;
|
||
}
|
||
if (name === "open-storage-artifact") {
|
||
await openStorageArtifactAction(action.dataset.fileId || "");
|
||
return;
|
||
}
|
||
if (name === "mark-tracking-read") {
|
||
try {
|
||
await markTrackingDigestRead();
|
||
rememberAction("日报已标记", "当前跟踪摘要已更新为已读,下次会从新的时间点继续汇总。", "green");
|
||
await bootstrap();
|
||
} catch (error) {
|
||
presentActionFailure(error, "标记日报已读失败");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "logout-session") {
|
||
await logoutSession();
|
||
return;
|
||
}
|
||
if (name === "dismiss-main-agent-landing") {
|
||
appState.mainAgentLanding = null;
|
||
renderAll();
|
||
return;
|
||
}
|
||
if (name === "goto-discovery") {
|
||
captureMainAgentLandingContext(action, "goto-discovery");
|
||
setScreen("discovery");
|
||
return;
|
||
}
|
||
if (name === "goto-intake") {
|
||
captureMainAgentLandingContext(action, "goto-intake");
|
||
setScreen("intake");
|
||
return;
|
||
}
|
||
if (name === "goto-automation") {
|
||
captureMainAgentLandingContext(action, "goto-automation");
|
||
setScreen("automation");
|
||
return;
|
||
}
|
||
if (name === "goto-playbook") {
|
||
captureMainAgentLandingContext(action, "goto-playbook");
|
||
setScreen("playbook");
|
||
return;
|
||
}
|
||
if (name === "goto-owned") {
|
||
setScreen("owned");
|
||
return;
|
||
}
|
||
if (name === "goto-tracking") {
|
||
captureMainAgentLandingContext(action, "goto-tracking");
|
||
setScreen("tracking");
|
||
return;
|
||
}
|
||
if (name === "goto-production") {
|
||
captureMainAgentLandingContext(action, "goto-production");
|
||
setScreen("production");
|
||
return;
|
||
}
|
||
if (name === "goto-strategy") {
|
||
captureMainAgentLandingContext(action, "goto-strategy");
|
||
setScreen("strategy");
|
||
return;
|
||
}
|
||
if (name === "goto-review") {
|
||
captureMainAgentLandingContext(action, "goto-review");
|
||
setScreen("review");
|
||
return;
|
||
}
|
||
if (name === "goto-settings") {
|
||
setScreen("settings");
|
||
return;
|
||
}
|
||
if (name === "goto-admin-workbench") {
|
||
setScreen("admin-workbench");
|
||
return;
|
||
}
|
||
if (name === "select-page-tab") {
|
||
const key = action.dataset.pageTabKey;
|
||
const value = action.dataset.pageTabValue;
|
||
if (key && value) {
|
||
appState[key] = value;
|
||
renderAll();
|
||
}
|
||
return;
|
||
}
|
||
if (name === "open-import-homepage") {
|
||
openImportHomepageAction();
|
||
return;
|
||
}
|
||
if (name === "open-import-selected-account") {
|
||
openImportSelectedAccountAction();
|
||
return;
|
||
}
|
||
if (name === "open-track-selected-account") {
|
||
openTrackSelectedAccountAction();
|
||
return;
|
||
}
|
||
if (name === "refresh-tracked-account") {
|
||
try {
|
||
await refreshTrackedAccountAction(action.dataset.trackedAccountId || "");
|
||
} catch (error) {
|
||
presentActionFailure(error, "同步跟踪账号失败");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "open-import-video-link") {
|
||
openImportVideoLinkAction();
|
||
return;
|
||
}
|
||
if (name === "open-import-text") {
|
||
openImportTextAction();
|
||
return;
|
||
}
|
||
if (name === "open-upload-video") {
|
||
openUploadVideoAction();
|
||
return;
|
||
}
|
||
if (name === "open-create-assistant") {
|
||
openCreateAssistantAction();
|
||
return;
|
||
}
|
||
if (name === "open-dashboard-project-switcher") {
|
||
openDashboardProjectSwitcher();
|
||
return;
|
||
}
|
||
if (name === "open-dashboard-action-reason") {
|
||
openDashboardActionReasonAction(action.dataset.dashboardActionIndex || "0");
|
||
return;
|
||
}
|
||
if (name === "select-dashboard-tab") {
|
||
appState.dashboardOverviewTab = action.dataset.dashboardTab || "project_progress";
|
||
renderAll();
|
||
return;
|
||
}
|
||
if (name === "open-oneliner-profile") {
|
||
openOneLinerProfileAction();
|
||
return;
|
||
}
|
||
if (name === "open-oneliner-profile-history") {
|
||
await openOneLinerProfileHistoryAction();
|
||
return;
|
||
}
|
||
if (name === "open-user-global-policy") {
|
||
openUserGlobalPolicyAction();
|
||
return;
|
||
}
|
||
if (name === "open-user-global-policy-history") {
|
||
await openUserGlobalPolicyHistoryAction();
|
||
return;
|
||
}
|
||
if (name === "open-user-platform-policy") {
|
||
openUserPlatformPolicyAction(action.dataset.platform || "");
|
||
return;
|
||
}
|
||
if (name === "open-user-platform-policy-history") {
|
||
await openUserPlatformPolicyHistoryAction(action.dataset.platform || "");
|
||
return;
|
||
}
|
||
if (name === "open-system-main-policy") {
|
||
openSystemMainPolicyAction();
|
||
return;
|
||
}
|
||
if (name === "open-system-main-policy-history") {
|
||
await openSystemMainPolicyHistoryAction();
|
||
return;
|
||
}
|
||
if (name === "open-system-platform-policy") {
|
||
openSystemPlatformPolicyAction(action.dataset.platform || "");
|
||
return;
|
||
}
|
||
if (name === "open-system-platform-policy-history") {
|
||
await openSystemPlatformPolicyHistoryAction(action.dataset.platform || "");
|
||
return;
|
||
}
|
||
if (name === "open-admin-override-target") {
|
||
await openAdminOverrideTargetAction();
|
||
return;
|
||
}
|
||
if (name === "open-admin-override-policy") {
|
||
openAdminOverridePolicyAction();
|
||
return;
|
||
}
|
||
if (name === "open-admin-override-history") {
|
||
await openAdminOverrideHistoryAction();
|
||
return;
|
||
}
|
||
if (name === "handoff-to-main-agent") {
|
||
try {
|
||
setBusy(true, "正在为主 Agent 创建执行计划...");
|
||
const payload = await createOneLinerRun({
|
||
source_screen: action.dataset.sourceScreen || appState.screen || "dashboard",
|
||
source_action_key: action.dataset.sourceActionKey || name,
|
||
title: action.dataset.title || action.textContent?.trim() || "交给主 Agent 处理",
|
||
summary: action.dataset.summary || "",
|
||
intent_key: action.dataset.intentKey || "custom",
|
||
platform: action.dataset.platform || getPreferredPlatform(),
|
||
platform_scope: action.dataset.platformScope || "single_platform",
|
||
plan_request: {
|
||
goal: action.dataset.goal || action.dataset.title || action.textContent?.trim() || "交给主 Agent 处理",
|
||
steps: parseJsonSafe(action.dataset.planSteps, []),
|
||
summary: action.dataset.summary || ""
|
||
}
|
||
});
|
||
appState.selectedOnelinerRunId = payload?.id || "";
|
||
openOneLinerPanel();
|
||
renderAll();
|
||
} catch (error) {
|
||
presentActionFailure(error, "主 Agent 接单失败");
|
||
openOneLinerPanel();
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "open-oneliner-run-result") {
|
||
openCurrentOneLinerRunResultAction(action.dataset.runId || "");
|
||
return;
|
||
}
|
||
if (name === "open-oneliner-run-context") {
|
||
try {
|
||
setBusy(true, "正在切到主 Agent 运行上下文...");
|
||
await openOneLinerRunContextAction(action.dataset.runId || "");
|
||
} catch (error) {
|
||
presentActionFailure(error, "打开主 Agent 运行失败");
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "confirm-oneliner-run") {
|
||
openConfirmOneLinerRunAction(action.dataset.runId || "");
|
||
return;
|
||
}
|
||
if (name === "cancel-oneliner-run") {
|
||
try {
|
||
setBusy(true, "正在取消当前任务...");
|
||
await cancelOneLinerRun(action.dataset.runId || "", "user cancelled");
|
||
} catch (error) {
|
||
presentActionFailure(error, "主 Agent 取消失败");
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "retry-oneliner-run") {
|
||
try {
|
||
setBusy(true, "正在重新生成待确认卡...");
|
||
await retryOneLinerRun(action.dataset.runId || "", "user requested retry");
|
||
openOneLinerPanel();
|
||
} catch (error) {
|
||
presentActionFailure(error, "主 Agent 重开失败");
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "select-oneliner-run") {
|
||
appState.selectedOnelinerRunId = action.dataset.runId || "";
|
||
renderAll();
|
||
return;
|
||
}
|
||
if (name === "select-oneliner-run-filter") {
|
||
appState.onelinerRunFilter = action.dataset.runFilter || "focus";
|
||
renderAll();
|
||
return;
|
||
}
|
||
if (name === "select-oneliner-session") {
|
||
appState.selectedOnelinerSessionId = action.dataset.sessionId || "";
|
||
await loadOneLinerMessages(appState.selectedOnelinerSessionId);
|
||
renderAll();
|
||
return;
|
||
}
|
||
if (name === "select-assistant") {
|
||
appState.selectedAssistantId = action.dataset.assistantId || "";
|
||
rememberAction("已切换当前 Agent", `当前默认 Agent 已更新为「${getSelectedAssistant()?.name || "未选择"}」。`, "green");
|
||
renderAll();
|
||
return;
|
||
}
|
||
if (name === "open-edit-assistant") {
|
||
openEditAssistantAction(action.dataset.assistantId || "");
|
||
return;
|
||
}
|
||
if (name === "open-platform-agent-profile") {
|
||
openPlatformAgentProfileAction(action.dataset.platform || "");
|
||
return;
|
||
}
|
||
if (name === "open-platform-agent-profile-history") {
|
||
await openPlatformAgentProfileHistoryAction(action.dataset.platform || "");
|
||
return;
|
||
}
|
||
if (name === "open-platform-agent-detail") {
|
||
await openPlatformAgentDetailAction(action.dataset.platform || "");
|
||
return;
|
||
}
|
||
if (name === "open-action-registry-edit") {
|
||
openActionRegistryEditAction(action.dataset.actionKey || "");
|
||
return;
|
||
}
|
||
if (name === "open-tenant-quota") {
|
||
openTenantQuotaAction();
|
||
return;
|
||
}
|
||
if (name === "run-oneliner-action") {
|
||
setBusy(true, "OneLiner 正在执行动作...");
|
||
try {
|
||
await executeOneLinerAction(action.dataset.executorKey || "", {
|
||
platform: action.dataset.platform || "",
|
||
sessionId: action.dataset.sessionId || "",
|
||
payload: collectOneLinerActionPayload(action)
|
||
});
|
||
} catch (error) {
|
||
presentActionFailure(error, "OneLiner 动作失败");
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "open-platform-agent-memory") {
|
||
openPlatformAgentMemoryAction(action.dataset.platform || "");
|
||
return;
|
||
}
|
||
if (name === "open-platform-agent-skill") {
|
||
openPlatformAgentSkillAction(action.dataset.platform || "");
|
||
return;
|
||
}
|
||
if (name === "review-platform-skill") {
|
||
openPlatformSkillReviewAction(action.dataset.platform || "", action.dataset.skillId || "", action.dataset.accepted !== "false");
|
||
return;
|
||
}
|
||
if (name === "rollback-platform-skill") {
|
||
openPlatformSkillRollbackAction(action.dataset.platform || "", action.dataset.skillId || "", action.dataset.versionId || "");
|
||
return;
|
||
}
|
||
if (name === "analyze-selected-account") {
|
||
openAnalyzeSelectedAccountAction();
|
||
return;
|
||
}
|
||
if (name === "analyze-top-videos") {
|
||
openAnalyzeTopVideosAction();
|
||
return;
|
||
}
|
||
if (name === "open-generate-copy") {
|
||
openGenerateCopyAction();
|
||
return;
|
||
}
|
||
if (name === "open-ai-video") {
|
||
openCreateAiVideoAction();
|
||
return;
|
||
}
|
||
if (name === "open-real-cut") {
|
||
openCreateRealCutAction();
|
||
return;
|
||
}
|
||
if (name === "open-create-review") {
|
||
openReviewAction();
|
||
return;
|
||
}
|
||
if (name === "open-preferred-model") {
|
||
openPreferredModelAction();
|
||
return;
|
||
}
|
||
if (name === "open-review-from-job") {
|
||
const jobId = action.dataset.jobId || "";
|
||
const fromDashboard = safeArray(appState.dashboard?.recent_jobs).find((item) => item.id === jobId) || null;
|
||
const fromDetail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;
|
||
openReviewAction({
|
||
sourceJobId: jobId,
|
||
sourceJob: fromDetail || fromDashboard,
|
||
title: (fromDetail || fromDashboard)?.title || ""
|
||
});
|
||
return;
|
||
}
|
||
if (name === "open-review-edit") {
|
||
const review = getReviewById(action.dataset.reviewId || "");
|
||
if (!review) {
|
||
rememberAction("复盘记录不存在", "当前没有找到这条复盘记录,请先刷新当前页面。", "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
openReviewAction({ review });
|
||
return;
|
||
}
|
||
if (name === "open-similar-search") {
|
||
openSimilaritySearchAction();
|
||
return;
|
||
}
|
||
if (name === "open-benchmark-link") {
|
||
openBenchmarkLinkAction();
|
||
return;
|
||
}
|
||
if (name === "save-candidate-benchmark") {
|
||
setBusy(true, "正在保存对标关系...");
|
||
try {
|
||
await saveCandidateAsBenchmark(action.dataset.candidateIndex || "");
|
||
} catch (error) {
|
||
presentActionFailure(error, "保存对标失败");
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "open-job-detail") {
|
||
openJobDetailAction(action.dataset.jobId || "");
|
||
return;
|
||
}
|
||
if (name === "recover-job") {
|
||
await openRecoverJobAction(action.dataset.jobId || "");
|
||
return;
|
||
}
|
||
if (name === "batch-recover-jobs") {
|
||
await openBatchRecoverJobsAction();
|
||
return;
|
||
}
|
||
if (name === "scan-admin-ops") {
|
||
await scanAdminOpsAction();
|
||
return;
|
||
}
|
||
if (name === "review-admin-incident") {
|
||
openAdminIncidentReviewAction(action.dataset.incidentId || "");
|
||
return;
|
||
}
|
||
if (name === "open-admin-repair-plan") {
|
||
openAdminRepairPlanAction(action.dataset.incidentId || "");
|
||
return;
|
||
}
|
||
if (name === "open-admin-fix-run-audit") {
|
||
openAdminFixRunAuditAction(action.dataset.runId || "");
|
||
return;
|
||
}
|
||
if (name === "open-admin-fix-run-detail") {
|
||
openAdminFixRunDetailAction(action.dataset.runId || "");
|
||
return;
|
||
}
|
||
if (name === "select-douyin-snapshot") {
|
||
await openDouyinSnapshotDetailAction(action.dataset.snapshotId || "");
|
||
return;
|
||
}
|
||
if (name === "job-to-ai-video") {
|
||
const jobId = action.dataset.jobId || "";
|
||
const detail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;
|
||
closeActionModal();
|
||
openCreateAiVideoAction({ sourceJobId: jobId, sourceJob: detail });
|
||
return;
|
||
}
|
||
if (name === "job-to-real-cut") {
|
||
const jobId = action.dataset.jobId || "";
|
||
const detail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;
|
||
closeActionModal();
|
||
openCreateRealCutAction({ sourceJobId: jobId, sourceJob: detail, objective: detail ? `基于任务「${detail.title}」保留高信息密度片段,输出适合短视频平台的粗剪结果。` : "" });
|
||
return;
|
||
}
|
||
if (name === "job-to-generate-copy") {
|
||
const jobId = action.dataset.jobId || "";
|
||
const detail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;
|
||
closeActionModal();
|
||
openGenerateCopyAction({ sourceJob: detail, brief: detail ? `基于任务「${detail.title}」的结果,生成一版可发布的短视频文案。参考摘要:${getJobSeedBrief(detail)}` : "" });
|
||
return;
|
||
}
|
||
if (name === "create-project") {
|
||
await createProject();
|
||
return;
|
||
}
|
||
if (name === "select-project") {
|
||
appState.selectedProjectId = action.dataset.projectId || "";
|
||
setBusy(true, "正在切换项目视图...");
|
||
try {
|
||
await loadStorageStatus(appState.selectedProjectId || "");
|
||
await loadAgentControlSurfaces(appState.selectedProjectId || "");
|
||
if (appState.selectedOnelinerSessionId) {
|
||
await loadOneLinerMessages(appState.selectedOnelinerSessionId);
|
||
} else {
|
||
appState.onelinerMessages = [];
|
||
}
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
renderAll();
|
||
return;
|
||
}
|
||
if (name === "select-platform") {
|
||
const nextPlatform = normalizePlatformValue(action.dataset.platform || "", "");
|
||
if (!nextPlatform || nextPlatform === getCurrentPlatformValue()) {
|
||
renderAll();
|
||
return;
|
||
}
|
||
setCurrentPlatform(nextPlatform);
|
||
setBusy(true, `正在切换到${platformLabel(nextPlatform)}...`);
|
||
try {
|
||
await bootstrap();
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
return;
|
||
}
|
||
if (name === "select-account") {
|
||
const accountId = action.dataset.accountId;
|
||
if (!accountId) return;
|
||
const requestToken = (appState.selectedAccountRequestToken || 0) + 1;
|
||
appState.selectedAccountRequestToken = requestToken;
|
||
setBusy(true, "正在加载对标详情...");
|
||
try {
|
||
const account = safeArray(appState.accounts).find((item) => item.id === accountId) || null;
|
||
const committed = await loadPlatformAccount(getAccountPlatform(account), accountId, requestToken);
|
||
if (committed && requestToken === appState.selectedAccountRequestToken) {
|
||
renderAll();
|
||
}
|
||
} catch (error) {
|
||
if (requestToken === appState.selectedAccountRequestToken) {
|
||
presentActionFailure(error, "加载对标详情失败");
|
||
}
|
||
} finally {
|
||
if (requestToken === appState.selectedAccountRequestToken) {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (name === "submit-oneliner") {
|
||
return;
|
||
}
|
||
if (name === "scroll-selected") {
|
||
document.getElementById("selected-account-anchor")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
return;
|
||
}
|
||
rememberAction("动作待接入", `前端还没有处理动作「${name}」,当前仍可继续通过 OneLiner 对话承接。`, "orange");
|
||
renderAll();
|
||
return;
|
||
}
|
||
});
|
||
|
||
document.addEventListener("input", (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) return;
|
||
if (target.dataset.action === "discovery-query") {
|
||
appState.discoveryQuery = target.value.trim();
|
||
screenMap.discovery.innerHTML = renderDiscoveryScreen();
|
||
}
|
||
});
|
||
|
||
document.addEventListener("submit", async (event) => {
|
||
const form = event.target;
|
||
if (!(form instanceof HTMLFormElement)) return;
|
||
if (form.dataset.role === "auth-form") {
|
||
event.preventDefault();
|
||
setBusy(true, "正在自动连接并加载...");
|
||
try {
|
||
appState.autoConnectSuppressed = false;
|
||
appState.autoConnectAttempted = false;
|
||
await ensureAutoSession({ force: true });
|
||
closeAuthModal();
|
||
await bootstrap();
|
||
} catch (error) {
|
||
const message = document.querySelector('[data-role="auth-message"]');
|
||
if (message) message.textContent = formatActionErrorMessage(error, "自动连接失败");
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
return;
|
||
}
|
||
if (form.dataset.role === "oneliner-form") {
|
||
event.preventDefault();
|
||
const input = form.querySelector('[data-role="oneliner-input"]');
|
||
const value = input instanceof HTMLTextAreaElement ? input.value.trim() : "";
|
||
if (!value) return;
|
||
setBusy(true, "OneLiner 正在拆解任务...");
|
||
try {
|
||
await submitOneLinerMessage(value);
|
||
if (input instanceof HTMLTextAreaElement) input.value = "";
|
||
openOneLinerPanel();
|
||
renderAll();
|
||
} catch (error) {
|
||
presentActionFailure(error, "OneLiner 调度失败");
|
||
openOneLinerPanel();
|
||
} finally {
|
||
setBusy(false, "");
|
||
}
|
||
}
|
||
});
|
||
|
||
navButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const next = button.dataset.screenTarget;
|
||
setScreen(next);
|
||
});
|
||
});
|
||
|
||
window.addEventListener("hashchange", () => {
|
||
const next = getScreenFromHash();
|
||
if (next !== appState.screen) {
|
||
setScreen(next, { updateHash: false });
|
||
}
|
||
});
|
||
|
||
window.addEventListener("resize", () => {
|
||
if (window.innerWidth > 760) {
|
||
setMobileSidebarOpen(false);
|
||
}
|
||
});
|
||
|
||
ensureAuthUi();
|
||
renderAll();
|
||
bootstrap();
|