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 `
${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))} 次视频
+
@@ -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"/);