diff --git a/CHANGELOG.md b/CHANGELOG.md index dc8aac7..92516ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -646,3 +646,7 @@ - 新增 Windows `ASR HTTP` 服务资产,兼容 StoryForge 当前 `/transcribe` 协议,便于把 ASR 迁到 Windows 主机 `192.168.31.18` - Windows 端新增 `ASR` 启动脚本、云端桥接脚本与计划任务注册脚本,并放通 `8088` 入站,保证局域网和公网都可直连该 `ASR` 服务 - 创作类表单的来源任务联动继续收口:`写复盘` 现在切换来源任务时,会同步推荐更合适的负责 Agent,并即时刷新顶部当前上下文摘要,避免标题、平台已经切过去了但负责人和上下文还停在旧任务上。 +- 套餐/额度页面补上“剩余额度预测”:额度页、额度面板和套餐预览现在都会明确显示剩余预算、剩余文案、剩余 AI 视频、剩余实拍和剩余存储,不再只展示总预算和总配额。 +- `创建 Agent / 编辑 Agent` 这两张表单也补成了带上下文和知识库联动的产品化表单:创建时切项目会同步刷新默认知识库,编辑时可以直接更新默认知识库,不必再回别处改。 +- 额度页残留的半成品口径已收口,不再出现“后端尚未完全接入真实预算”这类提示;未配置独立额度策略时,会直接引导按预算基线和动作池去建立试用、增长或规模套餐。 +- `smoke_public_storyforge.sh` 和 `smoke_fnos_storyforge_lan.sh` 现在会显式校验 `integrations/health` 的关键依赖状态、部署位置和 `local_model=not_configured` 口径,不再只看页面能打开和基础 healthz。 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 7afdddc..7382e31 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -535,6 +535,41 @@ function bindActionContextRecommendation(fields, options = {}) { sync(); } +function bindAssistantSheetRecommendations(fields, options = {}) { + const contextHtml = fields.querySelector('[data-action-field="context"] .sheet-html'); + const projectSelect = fields.querySelector('[data-action-field="projectId"]'); + const knowledgeBaseSelect = fields.querySelector('[data-action-field="knowledgeBaseId"]'); + const defaultProjectId = String(options.defaultProjectId || getSelectedProject()?.id || "").trim(); + const defaultKnowledgeBaseId = String(options.defaultKnowledgeBaseId || "").trim(); + const defaultAssistantId = String(options.defaultAssistantId || "").trim(); + const syncKnowledgeBaseOptions = () => { + if (!(knowledgeBaseSelect instanceof HTMLSelectElement)) return; + const projectId = projectSelect instanceof HTMLSelectElement ? projectSelect.value : defaultProjectId; + const knowledgeBases = getKnowledgeBaseOptions(projectId); + const currentValue = knowledgeBaseSelect.value || ""; + const fallbackValue = knowledgeBases.some((item) => item.value === currentValue) + ? currentValue + : knowledgeBases.some((item) => item.value === defaultKnowledgeBaseId) + ? defaultKnowledgeBaseId + : knowledgeBases[0]?.value || ""; + knowledgeBaseSelect.innerHTML = [{ value: "", label: "暂不绑定" }, ...knowledgeBases].map((item) => ` + + `).join(""); + knowledgeBaseSelect.value = fallbackValue; + }; + const syncContext = () => { + if (!(contextHtml instanceof HTMLElement)) return; + const projectId = projectSelect instanceof HTMLSelectElement ? projectSelect.value : defaultProjectId; + contextHtml.innerHTML = renderIntakeActionContextHtml(projectId, defaultAssistantId); + }; + const sync = () => { + syncKnowledgeBaseOptions(); + syncContext(); + }; + projectSelect?.addEventListener("change", sync); + sync(); +} + function getCompletedJobById(jobId = "") { const normalizedId = String(jobId || "").trim(); if (!normalizedId) return null; @@ -4812,6 +4847,7 @@ function renderTenantQuotaPanel() { const packageLabel = String(quotaConfig.package_title || quotaConfig.package_label || "").trim() || (hasHardLimit ? "自定义套餐" : "未设套餐"); const packageFocus = String(quotaConfig.package_focus || "").trim(); const warnThreshold = Number(quotaConfig.warn_threshold ?? 0.8); + const forecast = getTenantQuotaForecastValues(quota || {}, usage); const quotaTaskTitle = quota?.storage_over_limit ? "先处理存储超限" : quota?.enabled === false @@ -4840,12 +4876,12 @@ function renderTenantQuotaPanel() { ]; const cards = [ { label: "套餐档位", value: packageLabel, sub: packageFocus || `预警阈值 ${formatNumber((warnThreshold || 0.8) * 100)}%` }, - { 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)}` } + { label: "预算", value: `${formatNumber((quota?.monthly_budget_cents || 0) / 100)} 元`, sub: `已用 ${formatNumber((usage?.total_cost_cents || 0) / 100)} 元 · 剩余 ${formatNumber(forecast.remainingBudgetCents / 100)} 元` }, + { label: "分析配额", value: formatNumber(quota?.analysis_quota || 0), sub: `已用 ${formatNumber(categories.analysis?.quantity || 0)} · 剩余 ${formatNumber(forecast.remainingAnalysisQuota)}` }, + { label: "文案配额", value: formatNumber(quota?.copy_quota || 0), sub: `已用 ${formatNumber(categories.copy?.quantity || 0)} · 剩余 ${formatNumber(forecast.remainingCopyQuota)}` }, + { label: "AI 视频配额", value: formatNumber(quota?.ai_video_quota || 0), sub: `已用 ${formatNumber(categories.ai_video?.quantity || 0)} · 剩余 ${formatNumber(forecast.remainingAiVideoQuota)}` }, + { label: "实拍剪辑配额", value: formatNumber(quota?.real_cut_quota || 0), sub: `已用 ${formatNumber(categories.real_cut?.quantity || 0)} · 剩余 ${formatNumber(forecast.remainingRealCutQuota)}` }, + { label: "存储上限", value: formatBytes(quota?.storage_limit_bytes || 0), sub: `当前 ${formatBytes(usage?.storage_bytes || 0)} · 剩余 ${formatBytes(forecast.remainingStorageBytes)}` } ]; return `
@@ -4876,6 +4912,9 @@ function renderTenantQuotaPanel() { ${escapeHtml(`成本 ${(usage?.total_cost_cents || 0) / 100} 元`)} ${escapeHtml(`预警 ${(warnThreshold || 0.8) * 100}%`)}
+
+ ${renderTenantQuotaForecastTags(quota || {}, usage)} +
${quotaNotice}
${cards.map((item) => ` @@ -8139,6 +8178,7 @@ function renderCreditsScreen() { const quota = appState.tenantQuota; const usage = appState.tenantUsage || quota?.usage || {}; const categories = usage?.categories || {}; + const forecast = getTenantQuotaForecastValues(quota || {}, usage); 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; @@ -8179,9 +8219,12 @@ function renderCreditsScreen() { ${escapeHtml(formatNumber(categories.copy?.quantity || jobs.filter((item) => item.line_type === "analysis").length))} 条文案 ${escapeHtml(formatNumber(estimatedVideoUsage || jobs.filter((item) => item.line_type === "ai_video" || item.line_type === "real_cut").length))} 次视频
+
+ ${renderTenantQuotaForecastTags(quota || {}, usage)} +
文案消耗预估${escapeHtml(formatNumber(categories.copy?.quantity || jobs.filter((item) => item.line_type === "analysis").length))}
分析 / 生成链路按任务量估算
-
本周期预算${escapeHtml(formatNumber((quota?.monthly_budget_cents || usage?.total_cost_cents || 0) / 100))}
已用 ${escapeHtml(formatNumber((usage?.total_cost_cents || 0) / 100))} 元
+
本周期预算${escapeHtml(formatNumber((quota?.monthly_budget_cents || usage?.total_cost_cents || 0) / 100))}
已用 ${escapeHtml(formatNumber((usage?.total_cost_cents || 0) / 100))} 元 · 剩余 ${escapeHtml(formatNumber(forecast.remainingBudgetCents / 100))} 元
视频消耗预估${escapeHtml(formatNumber(estimatedVideoUsage || jobs.filter((item) => item.line_type === "ai_video" || item.line_type === "real_cut").length))}
AI 视频 / 实拍剪辑可做套餐
@@ -8191,11 +8234,11 @@ function renderCreditsScreen() {

预算与已用

-

${escapeHtml(quota ? `当前预算 ${formatNumber((quota.monthly_budget_cents || 0) / 100)} 元,已用 ${formatNumber((usage?.total_cost_cents || 0) / 100)} 元。` : "后端尚未完全接入真实预算,当前先按任务量做用户可理解的额度看板。")}

+

${escapeHtml(quota ? `当前预算 ${formatNumber((quota.monthly_budget_cents || 0) / 100)} 元,已用 ${formatNumber((usage?.total_cost_cents || 0) / 100)} 元,剩余 ${formatNumber(forecast.remainingBudgetCents / 100)} 元。` : "当前项目还没有独立额度策略,先按最近动作量和成本信号建立可执行的预算基线。")}

动作额度

-

${escapeHtml(quota ? `文案 ${formatNumber(quota.copy_quota || 0)} / AI 视频 ${formatNumber(quota.ai_video_quota || 0)} / 实拍剪辑 ${formatNumber(quota.real_cut_quota || 0)}。` : "当前先按文案、AI 视频、实拍剪辑三类动作池展示,便于先按项目阶段配置套餐。")}

+

${escapeHtml(quota ? `文案剩余 ${formatNumber(forecast.remainingCopyQuota)} / AI 视频剩余 ${formatNumber(forecast.remainingAiVideoQuota)} / 实拍剪辑剩余 ${formatNumber(forecast.remainingRealCutQuota)}。` : "文案、AI 视频和实拍剪辑会按动作池分开管理,适合先按项目阶段配置试用、增长或规模套餐。")}

使用建议

@@ -8274,6 +8317,49 @@ function getTenantQuotaPackagePreset(label) { return TENANT_QUOTA_PACKAGE_PRESETS[String(label || "").trim().toLowerCase()] || null; } +function getTenantQuotaForecastValues(values = {}, usage = null) { + const resolvedUsage = usage || appState.tenantUsage || {}; + const categories = resolvedUsage?.categories || {}; + const readValue = (camelKey, snakeKey) => Number(values?.[camelKey] ?? values?.[snakeKey] ?? 0); + const readUsageCount = (key) => Number(categories?.[key]?.quantity || 0); + const monthlyBudgetCents = readValue("monthlyBudgetCents", "monthly_budget_cents"); + const storageLimitBytes = readValue("storageLimitBytes", "storage_limit_bytes"); + const analysisQuota = readValue("analysisQuota", "analysis_quota"); + const copyQuota = readValue("copyQuota", "copy_quota"); + const aiVideoQuota = readValue("aiVideoQuota", "ai_video_quota"); + const realCutQuota = readValue("realCutQuota", "real_cut_quota"); + const recorderQuota = readValue("recorderQuota", "recorder_quota"); + const totalCostCents = Number(resolvedUsage?.total_cost_cents || 0); + const storageBytes = Number(resolvedUsage?.storage_bytes || 0); + return { + monthlyBudgetCents, + remainingBudgetCents: Math.max(monthlyBudgetCents - totalCostCents, 0), + storageLimitBytes, + remainingStorageBytes: Math.max(storageLimitBytes - storageBytes, 0), + analysisQuota, + remainingAnalysisQuota: Math.max(analysisQuota - readUsageCount("analysis"), 0), + copyQuota, + remainingCopyQuota: Math.max(copyQuota - readUsageCount("copy"), 0), + aiVideoQuota, + remainingAiVideoQuota: Math.max(aiVideoQuota - readUsageCount("ai_video"), 0), + realCutQuota, + remainingRealCutQuota: Math.max(realCutQuota - readUsageCount("real_cut"), 0), + recorderQuota, + remainingRecorderQuota: Math.max(recorderQuota - readUsageCount("live_recorder"), 0) + }; +} + +function renderTenantQuotaForecastTags(values, usage = null) { + const forecast = getTenantQuotaForecastValues(values, usage); + return ` + ${escapeHtml(`剩余预算 ${formatNumber(forecast.remainingBudgetCents / 100)} 元`)} + ${escapeHtml(`剩余文案 ${formatNumber(forecast.remainingCopyQuota)}`)} + ${escapeHtml(`剩余 AI 视频 ${formatNumber(forecast.remainingAiVideoQuota)}`)} + ${escapeHtml(`剩余实拍 ${formatNumber(forecast.remainingRealCutQuota)}`)} + ${escapeHtml(`剩余存储 ${formatBytes(forecast.remainingStorageBytes)}`)} + `; +} + function renderTenantQuotaPackagePreview(label, values = null) { const preset = getTenantQuotaPackagePreset(label); const packageLabel = String(label || "custom").trim() || "custom"; @@ -8298,6 +8384,9 @@ function renderTenantQuotaPackagePreview(label, values = null) { ${escapeHtml(`AI 视频 ${formatNumber(currentValues.aiVideoQuota || 0)}`)} ${escapeHtml(`实拍剪辑 ${formatNumber(currentValues.realCutQuota || 0)}`)}
+
+ ${renderTenantQuotaForecastTags(currentValues)} +
`; } @@ -11109,6 +11198,7 @@ function openCreateAssistantAction() { description: "先定义用途、平台与目标,再让 Agent 学习内容。", submitLabel: "创建 Agent", fields: [ + { name: "context", label: "当前上下文", type: "html", html: renderIntakeActionContextHtml(project.id, "") }, { name: "projectId", label: "归属项目", type: "select", value: project.id, options: getProjectOptions() }, { name: "name", label: "名称", placeholder: "例如:创业成交助手" }, { name: "description", label: "说明", placeholder: "例如:服务创业 IP 与成交型短视频" }, @@ -11117,6 +11207,10 @@ function openCreateAssistantAction() { { 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 } ], + onOpen: ({ fields }) => { + bindAssistantSheetRecommendations(fields, { defaultProjectId: project.id, defaultKnowledgeBaseId: kbOptions[0]?.value || "" }); + fields.querySelector('[data-action-field="name"]')?.focus(); + }, onSubmit: async (values) => { if (!values.name?.trim()) throw new Error("请填写 Agent 名称"); const projectId = values.projectId || project.id; @@ -11148,17 +11242,27 @@ function openEditAssistantAction(assistantId = "") { return; } const modelOptions = getModelOptions(); + const kbOptions = getKnowledgeBaseOptions(assistant.project_id || ""); openActionModal({ title: "编辑 Agent", description: "更新当前 Agent 的名称、目标和主模型,不会影响已完成任务。", submitLabel: "保存 Agent", fields: [ + { name: "context", label: "当前上下文", type: "html", html: renderIntakeActionContextHtml(assistant.project_id || "", assistant.id) }, { 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: "knowledgeBaseId", label: "默认知识库", type: "select", value: safeArray(assistant.knowledge_base_ids)[0] || "", options: [{ value: "", label: "暂不绑定" }, ...kbOptions] }, { name: "modelProfileId", label: "主模型", type: "select", value: assistant.model_profile_id || modelOptions[0]?.value || "", options: modelOptions } ], + onOpen: ({ fields }) => { + bindAssistantSheetRecommendations(fields, { + defaultProjectId: assistant.project_id || "", + defaultKnowledgeBaseId: safeArray(assistant.knowledge_base_ids)[0] || "", + defaultAssistantId: assistant.id + }); + }, onSubmit: async (values) => { if (!values.name?.trim()) throw new Error("请填写 Agent 名称"); const updated = await storyforgeFetch(`/v2/assistants/${encodeURIComponent(assistant.id)}`, { @@ -11168,6 +11272,7 @@ function openEditAssistantAction(assistantId = "") { description: values.description || "", generation_goal: values.goal || "", system_prompt: values.systemPrompt || "", + knowledge_base_ids: values.knowledgeBaseId ? [values.knowledgeBaseId] : [], model_profile_id: values.modelProfileId || "" } }); diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index e399c12..5099892 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -314,12 +314,23 @@ test("quota and review screens foreground live next-step guidance", () => { assert.doesNotMatch(storage, /后端暂未提供 \/v2\/storage\/status/); assert.match(storage, /当前实例没有返回存储策略时/); assert.doesNotMatch(credits, /后续再接真实套餐/); - assert.match(credits, /按项目阶段配置套餐/); + assert.doesNotMatch(credits, /后端尚未完全接入真实预算/); + assert.doesNotMatch(credits, /当前先按文案、AI 视频、实拍剪辑三类动作池展示/); + assert.match(credits, /当前项目还没有独立额度策略/); + assert.match(credits, /文案、AI 视频和实拍剪辑会按动作池分开管理/); + assert.match(credits, /按最近动作量和成本信号建立可执行的预算基线|按项目阶段配置试用、增长或规模套餐/); assert.match(credits, /预算、动作池和项目阶段绑定成正式套餐/); assert.match(tenantQuota, /套餐档位/); assert.match(tenantQuota, /预警阈值|预警 80%/); assert.match(APP, /const TENANT_QUOTA_PACKAGE_PRESETS =/); + assert.match(APP, /function getTenantQuotaForecastValues\(values = \{\}, usage = null\)/); + assert.match(APP, /function renderTenantQuotaForecastTags\(values, usage = null\)/); + assert.match(APP, /renderTenantQuotaForecastTags\(currentValues\)/); assert.match(APP, /function renderTenantQuotaPackagePreview/); + assert.match(tenantQuota, /renderTenantQuotaForecastTags\(quota \|\| \{\}, usage\)/); + assert.match(credits, /renderTenantQuotaForecastTags\(quota \|\| \{\}, usage\)/); + assert.match(credits, /剩余 \${escapeHtml\(formatNumber\(forecast\.remainingBudgetCents \/ 100\)\)} 元/); + assert.match(credits, /文案剩余 \${formatNumber\(forecast\.remainingCopyQuota\)}/); assert.match(quotaAction, /renderTenantQuotaPackagePreview/); assert.match(APP, /预设已锁定|支持自定义/); assert.match(quotaAction, /input\.disabled = Boolean\(preset\)/); @@ -1197,6 +1208,7 @@ test("input-heavy intake sheets surface current context and smarter defaults", ( assert.match(APP, /function recommendManualIntakeTitle\(project, platform, kind\)/); assert.match(APP, /function bindManualIntakeTitleRecommendation\(fields, kind, options = \{\}\)/); assert.match(APP, /function bindActionContextRecommendation\(fields, options = \{\}\)/); + assert.match(APP, /function bindAssistantSheetRecommendations\(fields, options = \{\}\)/); assert.match(APP, /function bindCreativeSourceJobRecommendations\(fields, options = \{\}\)/); assert.match(APP, /const contextHtml = fields\.querySelector\('\[data-action-field="context"] \.sheet-html'\);/); assert.match(APP, /const assistantSelect = fields\.querySelector\('\[data-action-field="assistantId"]'\);/); @@ -1386,7 +1398,15 @@ test("assistant actions return to the playbook workspace with the saved assistan const editAssistant = extractBetween(APP, "function openEditAssistantAction(assistantId = \"\")", "function openAnalyzeSelectedAccountAction()"); const clickHandler = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); assert.match(APP, /function focusPlaybookWorkspace\(assistantId = ""\)/); + assert.match(createAssistant, /label: "当前上下文", type: "html"/); + assert.match(createAssistant, /label: "默认知识库", type: "select"/); + assert.match(createAssistant, /bindAssistantSheetRecommendations\(fields, \{ defaultProjectId: project\.id, defaultKnowledgeBaseId: kbOptions\[0\]\?\.value \|\| "" \}\);/); + assert.match(createAssistant, /fields\.querySelector\('\[data-action-field="name"\]'\)\?\.focus\(\);/); assert.match(createAssistant, /focusPlaybookWorkspace\(assistant\.id\)/); + assert.match(editAssistant, /label: "当前上下文", type: "html"/); + assert.match(editAssistant, /label: "默认知识库", type: "select"/); + assert.match(editAssistant, /bindAssistantSheetRecommendations\(fields, \{/); + assert.match(editAssistant, /knowledge_base_ids:\s*values\.knowledgeBaseId \? \[values\.knowledgeBaseId\] : \[\]/); assert.match(editAssistant, /focusPlaybookWorkspace\(updated\.id\)/); assert.match(clickHandler, /name === "select-assistant"[\s\S]*focusPlaybookWorkspace\(appState\.selectedAssistantId\)/); assert.match(APP, /id="current-agent-anchor"/);