feat: improve web agent controls and capability detection
This commit is contained in:
@@ -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 视频任务
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user