diff --git a/PROGRESS.md b/PROGRESS.md index 5938e3d..97f74db 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-05-22 +- **媒体生成每账号每日配额(yaml 可配,默 20 图 / 5 视频)**:`config/agent.yaml` 加 `quotas` 段(`images_per_day: 20` / `videos_per_day: 5`),`core/storage/usage.py::check_daily_quota(user_id, kind, limit)` SELECT COUNT FROM usage_events WHERE user_id=? AND kind IN(image/video) AND created_at >= 本地今日 00:00,`limit<=0` 短路不查 DB。`SeedreamTool` / `SeedanceTool` ctor 新增 `daily_limit` 形参由 `agent_builder` 从 yaml 透传,`execute()` 起手 if 超额返 `[Error] 已达每日 X 生成上限(N/M),次日 00:00 重置` 不调远端不烧钱。tool 返串会进 LLM 上下文 → 模型据此对用户解释,所以**只暴露已用/上限 + 重置时间**,不写 `config/agent.yaml::quotas.X` 这种 yaml 路径(否则 LLM 倾向原文复述,SaaS 场景泄漏内部 schema 给外部用户;管理员要调改自己读代码/yaml 找,30 秒事)。**跨 task 跨 variant 全口径合计** —— 配额是账号级与具体 variant 无关(seedream + 未来的 seedream_pro 共享同一 20 张池)。失败任务不计 —— record_*_usage 只在成功+下载完才落库,失败 retry 不烧配额符合直觉。并发 race(同 user 跨 task 两次 check 同时过)可接受 —— 软上限非计费 hard cap,日级偶尔多 1 张不破坏保护意图,不加事务锁。否决:(a) env 变量(`ZCBOT_IMAGES_PER_DAY` 等)—— 配额是业务策略不是部署秘密 / 环境差异,跟现有 yaml 类参数(默认 size / 价格 / 超时)分工一致,且 yaml 带注释 + 多值组合扩展自然(未来加 audio_per_day);(b) AgentLoop 集中 pre-flight —— 给 loop 加配额映射反而散,tool 层自检每次只多一行 SQL 亚毫秒,符合"工具按原子操作切分";(c) 滑动 24h 窗口 —— 用户直觉是"今天用完了明天再来"的日历日,服务器本地 00:00 重置语义更顺;(d) tool 返串里贴 yaml 路径给管理员看 —— LLM 会向用户复述,泄漏内部 schema。 - **"+ 新建任务"按钮从 header 挪到任务面板 + 改通栏单独一行**:`web/static/dev.html` `#hd-new` 节点直接在 HTML 里挪到 `#pane-left`,放在第一行 `.pane-head`(任务标题 + 计数 + filter + 刷新 + 折叠)之下、搜索行之上的独立 `.pane-head` 行,`flex:1` 撑满整行(primary 红底通栏 CTA)。原本塞在第一行 spacer 之后,但 pane 320px 宽度下"+ 新建任务"中文五字会被挤断行,通栏解决根因。语义更贴(新建任务 = 任务面板的动作);顶栏减负只剩身份区(brand / 用户名 / 退出登录);两种模式 DOM 一致,顺手删了 `embedInit` 里动态 `insertBefore` 那段 + `@media phone` 里 `#hd-new` 紧凑覆盖(通栏环境不需要缩字号)。桌面 / 平板折叠态被 `#pane-left > * { display: none }` 自动藏掉,无需额外覆盖。 - **dev SPA 加 iframe embed 模式(`?embed=1&parent_origin=...`)**:`web/static/dev.html` 加 embed 模式 — 父页面 iframe 嵌入时藏左上 brand / 顶栏 `#hd-who` / 退出登录按钮(桌面段整层 header `display:none`,移动段保留 header 给 `.mobile-tabs` 切换用),JS `embedInit` 把 `#hd-new`("+ 新建任务")从 header 节点移到任务面板 pane-head(spacer 之后、`#filter-status` 之前,加 `small` class 跟周边按钮对齐高度)。postMessage 协议:iframe 启动发 `{type:"zcbot-ready"}` 给父端,父端调自家后端用 `PLATFORM_KEY` 走 zcbot 已有的 `POST /v1/auth/login` 拿 JWT,通过 `{type:"zcbot-token", token, user_id, user_name?}` 推回 iframe;iframe 写 localStorage + `enterApp()`。401 时改写 `logout()` 不再 `location.reload()`,而是发 `{type:"zcbot-401"}` 通知父端重换 token,期间显灰底等待层(`#embed-waiting` spinner + 文案);新加 css class `body.embed-mode` / `body.embed-mode.embed-waiting` 控制可见性。**安全要点**:`event.origin` 双向校验(白名单 = URL 参数 `parent_origin`),缺参数直接显错误占位拒收;`PLATFORM_KEY` 留在 platform 后端绝不下发浏览器。`web/EMBED.md` 写给 platform 工程的对接手册(URL / 协议 / Node/Python 后端示例 / 父端前端示例 / CORS / CSP frame-ancestors 收紧建议 / 调试 + 故障兜底表)。否决:(a) URL 参数直接传 token —— Referer / 浏览器历史泄漏面;(b) 同源 + 共享 localStorage —— 用户明确说不同源;(c) 拆 dev.html 进 platform SPA route —— 工作量爆炸。 - **dev SPA chat-input 支持 Ctrl+V 粘贴文件上传 + chat-hint 反馈**:`web/static/dev.html` 给 `#chat-input` 加 `paste` 监听 —— `e.clipboardData.files` 非空时 `preventDefault` + 复用现有 `uploadFiles(files)` 走 `/v1/files/upload` 落到 `state.filesPath`(与拖拽到右 pane 同通路);纯文本粘贴走默认不拦。`uploadFiles` 改返回 bool(成功 true / 失败 false,原 alert 行为不变);粘贴 handler 通过 `chat-hint` 广播 "上传中:…" → "已粘贴:"(4s 后回前一个 hint,同 `optimizePrompt` 救回范式,不破坏 streaming/optimizing 期间的状态)。失败仍走 alert,hint 立即恢复。placeholder 提示加 `Ctrl+V 可粘贴文件`。常见场景:截图后直接 Ctrl+V 入对话区当作素材上传,免去切窗口走右 pane 拖拽。 diff --git a/config/agent.yaml b/config/agent.yaml index b5b7f8d..6997fab 100644 --- a/config/agent.yaml +++ b/config/agent.yaml @@ -5,3 +5,10 @@ models_dir: config/models skills_dir: skills workspace_dir: workspace system_prompt: prompts/system/general_v1.md + +# 媒体生成每账号每日配额(usage_events.kind=image/video 计数,服务器本地 00:00 重置)。 +# 失败任务不算(record_*_usage 只在成功 + 下载完才落库)。≤ 0 视为不限。 +# 跨 task 跨 variant 全口径合计。改后 重启 web 生效。 +quotas: + images_per_day: 20 # seedream 等图像 tool 调用上限 + videos_per_day: 5 # seedance 等视频 tool 调用上限 diff --git a/core/agent_builder.py b/core/agent_builder.py index 3567b7a..0c73b02 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -355,6 +355,12 @@ def build_agent( rp = RunPythonTool(base_dir=tool_base, user_root=ur_path) tools[rp.name] = rp + # 每账号每日配额(yaml `quotas` 段,跨 task 跨 variant 全口径合计; + # 0 / 缺失 = 不限)。tool 起手 check_daily_quota,超额返 [Error] 不调远端。 + quotas = cfg.get("quotas") or {} + images_per_day = int(quotas.get("images_per_day", 0)) + videos_per_day = int(quotas.get("videos_per_day", 0)) + # 媒体生成 tool(豆包 seedream / 后续 seedance):仅当 ARK_API_KEY 设了才挂 —— # 没 key 的用户无感知,不至于看到 schema 里突然多个永远报错的工具。 # image_variant 由 caller 传(web 入口随消息 POST 带);空 → 取 yaml 第一个 variant @@ -384,6 +390,7 @@ def build_agent( user_id=uid, base_dir=tool_base, user_root=ur_path, + daily_limit=images_per_day, ) tools[seedream_tool.name] = seedream_tool @@ -413,6 +420,7 @@ def build_agent( base_dir=tool_base, user_root=ur_path, cancel_check=cancel_check, + daily_limit=videos_per_day, ) tools[seedance_tool.name] = seedance_tool diff --git a/core/storage/usage.py b/core/storage/usage.py index 05d3072..5596cf9 100644 --- a/core/storage/usage.py +++ b/core/storage/usage.py @@ -9,11 +9,12 @@ chat 类型的入口由 loop.py 在 assistant message 入库后调用;媒体工 """ from __future__ import annotations +from datetime import datetime from decimal import Decimal from typing import Any, Mapping, Optional from uuid import UUID -from sqlalchemy import update +from sqlalchemy import func, select, update from .engine import session_scope from .models import Message, UsageEvent @@ -190,3 +191,31 @@ def record_video_usage( cost_cny=cost_cny, )) return cost_cny + + +def check_daily_quota(*, user_id: UUID, kind: str, limit: int) -> tuple[int, bool]: + """每账号每日 kind=image/video 调用配额检查。返回 (今日已用次数, 是否超额)。 + + `limit <= 0` 视为不限,直接返回 (0, False) 不查 DB。 + "一天"=服务器本地 00:00 起算(用户直觉日历日;非滑动 24h 窗口)。 + 失败任务不计 —— `record_*_usage` 只在成功+下载完才落库 → 失败 retry 不烧配额。 + 跨 task 跨 variant 全口径合计 —— 配额是账号级,与具体调用哪个 variant 无关。 + + 并发 race:同 user 两次调用同时通过 check 会两个都跑成功(off-by-one), + 可接受 —— 单 task 单活 run gate 已经挡了同 task 并发;跨 task 罕见, + 且这是软上限(非计费 hard cap),日级偶尔多 1 张不影响保护意图。 + """ + if limit <= 0: + return 0, False + now_local = datetime.now().astimezone() + today_start = now_local.replace(hour=0, minute=0, second=0, microsecond=0) + with session_scope() as s: + used = s.execute( + select(func.count()).select_from(UsageEvent).where( + UsageEvent.user_id == user_id, + UsageEvent.kind == kind, + UsageEvent.created_at >= today_start, + ) + ).scalar_one() + used_int = int(used) + return used_int, used_int >= int(limit) diff --git a/tools/seedance.py b/tools/seedance.py index 0270f03..77427a9 100644 --- a/tools/seedance.py +++ b/tools/seedance.py @@ -26,7 +26,7 @@ from typing import Any, Callable, Optional from uuid import UUID from core.ark_client import ArkClient, ArkConfig, ArkError -from core.storage.usage import record_video_usage +from core.storage.usage import check_daily_quota, record_video_usage from .base import Tool @@ -125,6 +125,7 @@ class SeedanceTool(Tool): base_dir: Optional[Path] = None, user_root: Optional[Path] = None, cancel_check: Optional[Callable[[], bool]] = None, + daily_limit: int = 0, ) -> None: super().__init__(base_dir, user_root=user_root) self.ark_cfg = ark_cfg @@ -134,6 +135,7 @@ class SeedanceTool(Tool): self.task_id = task_id self.user_id = user_id self.cancel_check = cancel_check # 轮询期间检查是否被 cancel + self.daily_limit = int(daily_limit) # 0 / 负 = 不限;由 agent_builder 从 yaml quotas 透传 def execute( self, @@ -147,6 +149,18 @@ class SeedanceTool(Tool): if not (prompt or "").strip(): return "[Error] prompt 不能为空" + # 每账号每日配额(yaml quotas.videos_per_day)。失败 / cancel 不计,因为 + # record_video_usage 只在 succeeded+下载完才落库。tool 返串会进 LLM 上下文 + # → 模型据此向用户解释,所以**只暴露用户该看的部分**(已用/上限 + 重置时间), + # 内部 yaml 路径不进对话(管理员要改的地方读代码/yaml 自己找)。 + if self.daily_limit > 0: + used, over = check_daily_quota(user_id=self.user_id, kind="video", limit=self.daily_limit) + if over: + return ( + f"[Error] 已达每日视频生成上限({used}/{self.daily_limit} 个)," + f"次日 00:00 重置。" + ) + cfg = self.cfg model_id = cfg["model_id"] chosen_resolution = resolution or cfg.get("default_resolution", "720p") diff --git a/tools/seedream.py b/tools/seedream.py index 2795ef0..78bcd26 100644 --- a/tools/seedream.py +++ b/tools/seedream.py @@ -17,7 +17,7 @@ from typing import Any, Optional from uuid import UUID from core.ark_client import ArkClient, ArkConfig, ArkError -from core.storage.usage import record_image_usage +from core.storage.usage import check_daily_quota, record_image_usage from .base import Tool @@ -64,6 +64,7 @@ class SeedreamTool(Tool): user_id: UUID, base_dir: Optional[Path] = None, user_root: Optional[Path] = None, + daily_limit: int = 0, ) -> None: super().__init__(base_dir, user_root=user_root) self.ark_cfg = ark_cfg @@ -72,6 +73,7 @@ class SeedreamTool(Tool): self.working_dir = Path(working_dir) self.task_id = task_id self.user_id = user_id + self.daily_limit = int(daily_limit) # 0 / 负 = 不限;由 agent_builder 从 yaml quotas 透传 def execute( self, @@ -83,6 +85,18 @@ class SeedreamTool(Tool): if not (prompt or "").strip(): return "[Error] prompt 不能为空" + # 每账号每日配额(yaml quotas.images_per_day)。失败 retry 不计,因为 + # record_image_usage 只在成功+下载完才落库。tool 返串会进 LLM 上下文, + # 模型据此向用户解释,所以**只暴露用户该看的部分**(已用/上限 + 重置时间), + # 内部 yaml 路径不进对话(管理员要改的地方读代码/yaml 自己找)。 + if self.daily_limit > 0: + used, over = check_daily_quota(user_id=self.user_id, kind="image", limit=self.daily_limit) + if over: + return ( + f"[Error] 已达每日图片生成上限({used}/{self.daily_limit} 张)," + f"次日 00:00 重置。" + ) + cfg = self.cfg model_id = cfg["model_id"] chosen_size = size or cfg.get("default_size", "2048x2048")