feat: refine assistant sheets with project-aware knowledge bases
Some checks failed
StoryForge CI / Baseline checks (push) Has been cancelled
StoryForge CI / Backend tests (push) Has been cancelled
StoryForge CI / Web tests (push) Has been cancelled

This commit is contained in:
kris
2026-04-07 13:41:53 +08:00
parent 670f631475
commit b72427eea8
3 changed files with 139 additions and 10 deletions

View File

@@ -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。

View File

@@ -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) => `
<option value="${escapeHtml(item.value)}">${escapeHtml(item.label)}</option>
`).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 `
<div class="panel pad">
@@ -4876,6 +4912,9 @@ function renderTenantQuotaPanel() {
<span class="tag">${escapeHtml(`成本 ${(usage?.total_cost_cents || 0) / 100}`)}</span>
<span class="tag">${escapeHtml(`预警 ${(warnThreshold || 0.8) * 100}%`)}</span>
</div>
<div class="compact-summary-row" style="margin-top:10px;">
${renderTenantQuotaForecastTags(quota || {}, usage)}
</div>
${quotaNotice}
<div class="mini-grid" style="margin-top:14px;">
${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() {
<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="mobile-only compact-summary-row" style="margin-bottom:14px;">
${renderTenantQuotaForecastTags(quota || {}, usage)}
</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((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))} 元 · 剩余 ${escapeHtml(formatNumber(forecast.remainingBudgetCents / 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;">
@@ -8191,11 +8234,11 @@ function renderCreditsScreen() {
<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>
<p>${escapeHtml(quota ? `当前预算 ${formatNumber((quota.monthly_budget_cents || 0) / 100)} 元,已用 ${formatNumber((usage?.total_cost_cents || 0) / 100)},剩余 ${formatNumber(forecast.remainingBudgetCents / 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)}` : "当前先按文案、AI 视频实拍剪辑三类动作池展示,便于先按项目阶段配置套餐。")}</p>
<p>${escapeHtml(quota ? `文案剩余 ${formatNumber(forecast.remainingCopyQuota)} / AI 视频剩余 ${formatNumber(forecast.remainingAiVideoQuota)} / 实拍剪辑剩余 ${formatNumber(forecast.remainingRealCutQuota)}` : "文案、AI 视频实拍剪辑会按动作池分开管理,适合先按项目阶段配置试用、增长或规模套餐。")}</p>
</div>
<div class="task-item">
<h4>使用建议</h4>
@@ -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 `
<span class="tag green">${escapeHtml(`剩余预算 ${formatNumber(forecast.remainingBudgetCents / 100)}`)}</span>
<span class="tag">${escapeHtml(`剩余文案 ${formatNumber(forecast.remainingCopyQuota)}`)}</span>
<span class="tag">${escapeHtml(`剩余 AI 视频 ${formatNumber(forecast.remainingAiVideoQuota)}`)}</span>
<span class="tag">${escapeHtml(`剩余实拍 ${formatNumber(forecast.remainingRealCutQuota)}`)}</span>
<span class="tag">${escapeHtml(`剩余存储 ${formatBytes(forecast.remainingStorageBytes)}`)}</span>
`;
}
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) {
<span class="tag">${escapeHtml(`AI 视频 ${formatNumber(currentValues.aiVideoQuota || 0)}`)}</span>
<span class="tag">${escapeHtml(`实拍剪辑 ${formatNumber(currentValues.realCutQuota || 0)}`)}</span>
</div>
<div class="task-meta">
${renderTenantQuotaForecastTags(currentValues)}
</div>
</div>
`;
}
@@ -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 || ""
}
});

View File

@@ -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"/);