feat: improve web agent controls and capability detection

This commit is contained in:
kris
2026-03-23 07:09:20 +08:00
parent 4ab0b26821
commit 54afca2bf4
3 changed files with 150 additions and 8 deletions

View File

@@ -48,6 +48,8 @@
- 导入文本素材并触发分析
- 上传本地视频并触发分析
- 创建 Agent
- 选择当前 Agent
- 编辑 Agent 的名称、目标、系统提示词和主模型
- 对当前 Douyin 对标账号重跑分析
- 批量分析高分作品
- 查找相似对标账号
@@ -62,6 +64,7 @@
- 在生产中心 / 发布与复盘常驻最近一次任务详情摘要
- 在 Web 中直接创建和编辑复盘
- 在页面里直接看到 `本机模型 / cutvideo / huobao / n8n / ASR` 的真实健康状态
- 会先识别后端是否具备 `tracking / reviews / integrations` 路由,再决定是否请求,避免不同版本 live collector 刷 404
- 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因
- 使用 Agent 生成文案
- 创建 AI 视频任务

View File

@@ -25,6 +25,7 @@ const appState = {
reviews: [],
integrationHealth: null,
localModelCatalog: null,
backendCapabilities: null,
busy: false,
message: "",
lastAction: null,
@@ -488,6 +489,21 @@ async function storyforgeFetch(path, options = {}) {
return payload;
}
async function loadBackendCapabilities(backendUrl) {
const normalizedUrl = (backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, "");
const response = await fetch(`${normalizedUrl}/openapi.json`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
return new Set(Object.keys(payload.paths || {}));
}
function backendSupports(path) {
if (!(appState.backendCapabilities instanceof Set)) return true;
return appState.backendCapabilities.has(path);
}
async function loginWithForm() {
const auth = readAuthForm();
if (!auth.backendUrl) {
@@ -540,6 +556,7 @@ async function logoutSession() {
appState.trackingDigest = null;
appState.reviews = [];
appState.integrationHealth = null;
appState.backendCapabilities = null;
appState.lastAction = null;
appState.lastGeneratedCopy = null;
appState.lastSimilaritySearch = null;
@@ -598,25 +615,37 @@ async function bootstrap() {
renderAll();
return;
}
appState.backendCapabilities = await loadBackendCapabilities(appState.session.backendUrl).catch(() => null);
const supportsTracking = backendSupports("/v2/douyin/tracking/accounts");
const supportsTrackingDigest = backendSupports("/v2/douyin/tracking/digest");
const supportsReviews = backendSupports("/v2/reviews");
const supportsIntegrationHealth = backendSupports("/v2/integrations/health");
const supportsLocalModels = backendSupports("/v2/integrations/local-models");
const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog] = await Promise.all([
storyforgeFetch("/v2/me/dashboard"),
storyforgeFetch("/v2/content-sources").catch(() => []),
storyforgeFetch("/v2/douyin/accounts").catch(() => []),
storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })),
storyforgeFetch("/v2/reviews").catch(() => []),
storyforgeFetch("/v2/integrations/health").catch(() => null),
storyforgeFetch("/v2/integrations/local-models").catch(() => null)
supportsTracking ? storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })) : Promise.resolve({ items: [], cursor_last_seen_at: "" }),
supportsReviews ? storyforgeFetch("/v2/reviews").catch(() => []) : Promise.resolve([]),
supportsIntegrationHealth ? storyforgeFetch("/v2/integrations/health").catch(() => null) : Promise.resolve(null),
supportsLocalModels ? storyforgeFetch("/v2/integrations/local-models").catch(() => null) : Promise.resolve(null)
]);
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
if (trackingCursorLastSeenAt) {
setLastSeenAt(trackingCursorLastSeenAt);
}
const trackingSince = trackingCursorLastSeenAt || getTrackingSinceIso();
const trackingDigest = await storyforgeFetch(`/v2/douyin/tracking/digest?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({
const trackingDigest = supportsTrackingDigest
? await storyforgeFetch(`/v2/douyin/tracking/digest?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({
items: [],
tracked_accounts: [],
cursor_last_seen_at: trackingCursorLastSeenAt
}))
: ({
items: [],
tracked_accounts: [],
cursor_last_seen_at: trackingCursorLastSeenAt
}));
});
appState.dashboard = dashboard;
appState.contentSources = safeArray(contentSources);
appState.accounts = safeArray(accounts);
@@ -650,6 +679,11 @@ async function bootstrap() {
}
async function markTrackingDigestRead() {
if (!backendSupports("/v2/douyin/tracking/cursor")) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入跟踪已读游标。", "orange");
renderAll();
return;
}
const nextSeenAt = new Date().toISOString();
await storyforgeFetch("/v2/douyin/tracking/cursor", {
method: "POST",
@@ -659,6 +693,11 @@ async function markTrackingDigestRead() {
}
async function refreshTrackingAccountsAction() {
if (!backendSupports("/v2/douyin/tracking/refresh")) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入批量跟踪同步。", "orange");
renderAll();
return;
}
setBusy(true, "正在同步跟踪账号...");
try {
const payload = await storyforgeFetch("/v2/douyin/tracking/refresh", {
@@ -680,6 +719,11 @@ async function refreshTrackedAccountAction(trackedAccountId) {
if (!trackedAccountId) {
throw new Error("trackedAccountId is required");
}
if (!backendSupports("/v2/douyin/tracking/accounts/{tracked_account_id}/refresh")) {
rememberAction("当前后端暂不支持", "这套 live collector 还没有接入单账号跟踪同步。", "orange");
renderAll();
return;
}
setBusy(true, "正在同步该跟踪账号...");
try {
const payload = await storyforgeFetch(`/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`, {
@@ -1689,6 +1733,14 @@ function renderTrackingScreen() {
if (!appState.dashboard) {
return screenShell("跟踪账号", "登录后才能生成真实日报。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("日报未加载", "当前还没有可用的对标账号数据。"));
}
if (!backendSupports("/v2/douyin/tracking/accounts")) {
return screenShell(
"跟踪账号",
"当前 live collector 还没有接入跟踪日报接口。",
`${button("跳到找对标", "goto-discovery", "primary")}`,
renderEmptyState("跟踪能力暂未接入", "这套后端还缺 /v2/douyin/tracking/*,等 live collector 同步后这里会自动切成真实日报。")
);
}
const trackedAccounts = safeArray(appState.trackingAccounts);
const digestItems = getTrackingDigestItems(12);
const cursorLabel = appState.lastSeenAt ? formatDateTime(appState.lastSeenAt) : "尚未记录";
@@ -1845,11 +1897,12 @@ function renderPlaybookScreen() {
const assistants = safeArray(appState.dashboard.assistants);
const models = safeArray(appState.dashboard.model_profiles);
const currentModel = getCurrentModelProfile();
const currentAssistant = getSelectedAssistant();
const localCatalog = appState.localModelCatalog || {};
const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean);
return screenShell(
"Agent",
"这里接真实 Agent 列表,后面再继续补创建和编辑动作。",
"这里接真实 Agent 列表,当前已经支持切换和编辑 Agent。",
`${button("设主模型", "open-preferred-model")} ${button("新建 Agent", "open-create-assistant")} ${button("生成文案", "open-generate-copy")} ${button("去生产", "goto-production", "primary")}`,
`
<div class="hero-card">
@@ -1859,6 +1912,29 @@ function renderPlaybookScreen() {
${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 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="margin-top:18px;">
<div class="panel-head">
<div>
@@ -1894,12 +1970,14 @@ function renderPlaybookScreen() {
<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">
<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>`}
@@ -2017,6 +2095,14 @@ function renderReviewScreen() {
if (!appState.dashboard) {
return screenShell("发布与复盘", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("复盘未加载", "登录后这里会先用最近任务生成一版复盘入口。"));
}
if (!backendSupports("/v2/reviews")) {
return screenShell(
"发布与复盘",
"当前 live collector 还没有接入复盘读写接口。",
`${button("去生产", "goto-production", "primary")}`,
renderEmptyState("复盘能力暂未接入", "这套后端还缺 /v2/reviews当前可以继续跑生产任务等 live collector 同步后这里会自动切成真实复盘工作台。")
);
}
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);
@@ -2595,6 +2681,43 @@ function openCreateAssistantAction() {
});
}
function openEditAssistantAction(assistantId = "") {
const assistant = safeArray(appState.dashboard?.assistants).find((item) => item.id === assistantId) || getSelectedAssistant();
if (!assistant) {
alert("请先选择一个 Agent");
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();
openActionModal({
@@ -3149,6 +3272,16 @@ document.addEventListener("click", async (event) => {
openCreateAssistantAction();
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 === "analyze-selected-account") {
openAnalyzeSelectedAccountAction();
return;

View File

@@ -629,6 +629,12 @@ select {
padding: 15px;
}
.task-item.active {
border-color: rgba(79, 143, 238, 0.24);
background: linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
box-shadow: inset 0 0 0 1px rgba(79, 143, 238, 0.08);
}
.task-item h4,
.entity-card h4,
.topic-card h4,