diff --git a/AGENTS.md b/AGENTS.md index 83a37a6..3fe5821 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,16 +59,40 @@ yu-zhi-ran/ - `USE_POSTGRES=false` falls back to SQLite (used in tests) - Models have timezone-aware `DateTime(timezone=True)` columns +### Content quality architecture (三平台差异化) + +**核心原则:三平台不再共享同一篇 markdown,各自独立展开。** +- `writer.py` 对 zhihu/wechat/xiaohongshu 分别调用 `generate_platform_markdown(platform)`,各走不同的 `section_expansion_{platform}` prompt +- 知乎:数据分析深度(400-800字/节),用「你」称呼读者 +- 公众号:个人叙事对话感(300-500字/节),用「我」口吻 +- 小红书:精炼干货(100-200字/节),直接给方法,可用 emoji + +**字数配置:** +- 知乎 min 3000 / max 8000 +- 公众号 min 2000 / max 4000 +- 小红书 min 400 / max 1000 +- 存于 `platform_configs` 表,`min_words` / `max_words` 字段,后台「平台管理」可改 + +**AI 套话检测:** +- DB 存储:`content_clean_rules` 表,`rule_type='ai_telltale'` +- 由 `config_items.py` 中的 `DEFAULT_CONTENT_CLEAN_RULES` 种子数据 +- `compliance_checker.py` 运行时从 DB 加载(`_load_ai_telltales()`),DB 不可用时回退代码硬编码列表 +- 后台「配置管理→敏感词/清理规则」可增删改(操作 `ContentCleanRule` 表) + ### Prompts (`prompt_configs` table) - **DB 是唯一来源**,修改 prompt 直接 `UPDATE prompt_configs SET content = '...' WHERE key = '...';` - 代码 `scripts/prompt_loader.py` 中的 `_PROMPT_DEFAULTS` **仅作种子数据**,第一次写入后就不再生效 - 新增 prompt:在 `_PROMPT_DEFAULTS` 添加定义 → 重启后自动补入 DB(仅当该 key 不存在时) - 修改 prompt:**直接改 DB,不要改代码**(除非要更新种子供新环境用) - DB 不可用时回退代码默认值(仅紧急模式) +- 三平台独立 section_expansion 提示词 key: `section_expansion_zhihu` / `section_expansion_wechat` / `section_expansion_xiaohongshu` +- 标题提示词去套路化,使用自然语言(不像 AI 写的 prompt) ### Prompt quality checks (`compliance_checker.py`) - 软质量问题(AI套话/人称混用/阅读体验)只降分、不挡流程(`passed=true`) - 硬合规问题(敏感词/法律/品牌)扣分多且阻塞流程 +- AI 套话从 DB `content_clean_rules(rule_type='ai_telltale')` 加载,后台可动态管理 +- 人称检查修正:去掉「大家」误报,仅检查「你们」和「你」混用 ### LLM - `call_llm()` in `core/nvidia_client.py` — reads active provider from DB `LLMConfig.is_active`, API key from env diff --git a/PROGRESS.md b/PROGRESS.md index 0fdc30a..2cb0d38 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -3,7 +3,7 @@ > 本文件为项目进度唯一真理源,所有进度信息以此为准。 > 其他文档中的进度描述一律以本文为准。 -**最后更新**:2026-06-02 (v18) +**最后更新**:2026-06-05 (v19) --- @@ -154,6 +154,9 @@ | 搜索提供商重构 | 2026-05-28 | 移除 opencode MCP(配额耗尽),新增 360/搜狗/微信搜索(免 Key),百度千帆日限 50→200 | | opencode 冗余代码清理 | 2026-06-02 | 删除 opencode_search.py / mcp_search_server.py / _call_mcp / 前后端所有 opencode/MCP 引用;禁用搜索缓存定时任务 | | 任务抽屉产出记录优化 | 2026-06-03 | drawer 产出结果 tab 优先显示日志消息(last_message),过滤 None/空值;修复 tasks.py 因 opencode 清理导致的 SyntaxError;清理 DB 中旧搜索缓存的 56 条 TaskLog + 1 条 TaskConfig | +| Hermes gateway 用户级服务恢复 | 2026-06-04 | 系统级服务 → 用户级(MemoryMax=1G 生效);删除 system 级文件;linger + default.target.wants 开机自启 | +| v-cloak 修复 Vue 模板闪烁 | 2026-06-04 | 9 页 `
]*>(好的|好的,|好[的,]|我来|让我|我将|我这就)", "description": "AI思考-HTML模式", "sort_order": 16}, + {"rule_type": "ai_telltale", "pattern": r"值得注意的是", "description": "AI套话—值得注意的是", "sort_order": 20}, + {"rule_type": "ai_telltale", "pattern": r"首先.*其次.*最后", "description": "AI套话—首先其次最后", "sort_order": 21}, + {"rule_type": "ai_telltale", "pattern": r"综上所述", "description": "AI套话—综上所述", "sort_order": 22}, + {"rule_type": "ai_telltale", "pattern": r"总的来说", "description": "AI套话—总的来说", "sort_order": 23}, + {"rule_type": "ai_telltale", "pattern": r"总而言之", "description": "AI套话—总而言之", "sort_order": 24}, + {"rule_type": "ai_telltale", "pattern": r"无可否认", "description": "AI套话—无可否认", "sort_order": 25}, + {"rule_type": "ai_telltale", "pattern": r"毋庸置疑", "description": "AI套话—毋庸置疑", "sort_order": 26}, + {"rule_type": "ai_telltale", "pattern": r"众所周知", "description": "AI套话—众所周知", "sort_order": 27}, + {"rule_type": "ai_telltale", "pattern": r"不可否认", "description": "AI套话—不可否认", "sort_order": 28}, + {"rule_type": "ai_telltale", "pattern": r"从某种意义上", "description": "AI套话—从某种意义上", "sort_order": 29}, + {"rule_type": "ai_telltale", "pattern": r"从某种程度上", "description": "AI套话—从某种程度上", "sort_order": 30}, + {"rule_type": "ai_telltale", "pattern": r"在一定程度上", "description": "AI套话—在一定程度上", "sort_order": 31}, + {"rule_type": "ai_telltale", "pattern": r"换而言之", "description": "AI套话—换而言之", "sort_order": 32}, + {"rule_type": "ai_telltale", "pattern": r"换言之", "description": "AI套话—换言之", "sort_order": 33}, + {"rule_type": "ai_telltale", "pattern": r"归根结底", "description": "AI套话—归根结底", "sort_order": 34}, + {"rule_type": "ai_telltale", "pattern": r"说到底", "description": "AI套话—说到底", "sort_order": 35}, + {"rule_type": "ai_telltale", "pattern": r"这为我们提供了", "description": "AI套话—这为我们提供了", "sort_order": 36}, + {"rule_type": "ai_telltale", "pattern": r"引人深思", "description": "AI套话—引人深思", "sort_order": 37}, + {"rule_type": "ai_telltale", "pattern": r"毫无悬念", "description": "AI套话—毫无悬念", "sort_order": 38}, + {"rule_type": "ai_telltale", "pattern": r"不知大家", "description": "AI套话—不知大家", "sort_order": 39}, + {"rule_type": "ai_telltale", "pattern": r"说回到", "description": "AI套话—说回到", "sort_order": 40}, + {"rule_type": "ai_telltale", "pattern": r"如果你也", "description": "AI套话—如果你也", "sort_order": 41}, + {"rule_type": "ai_telltale", "pattern": r"我们不难发现", "description": "AI套话—我们不难发现", "sort_order": 42}, + {"rule_type": "ai_telltale", "pattern": r"我们可以看出", "description": "AI套话—我们可以看出", "sort_order": 43}, + {"rule_type": "ai_telltale", "pattern": r"从以上分析可以看出", "description": "AI套话—从以上分析可以看出", "sort_order": 44}, + {"rule_type": "ai_telltale", "pattern": r"来说说到底", "description": "AI套话—来说说到底", "sort_order": 45}, ] class KeywordDomainMapResponse(BaseModel): @@ -193,6 +219,14 @@ def _ensure_defaults(db: Session): if db.query(ContentCleanRule).count() == 0: for item in DEFAULT_CONTENT_CLEAN_RULES: db.add(ContentCleanRule(**item)) + else: + # 补入缺失的 ai_telltale 规则(不覆盖已存在的内容) + existing_patterns = {r.pattern for r in db.query(ContentCleanRule).filter(ContentCleanRule.rule_type == 'ai_telltale').all()} + for item in DEFAULT_CONTENT_CLEAN_RULES: + if item['rule_type'] == 'ai_telltale' and item['pattern'] not in existing_patterns: + db.add(ContentCleanRule(**item)) + if any(item['rule_type'] == 'ai_telltale' and item['pattern'] not in existing_patterns for item in DEFAULT_CONTENT_CLEAN_RULES): + db.flush() if db.query(TrendFieldMapping).count() == 0: for item in DEFAULT_TREND_FIELD_MAP: db.add(TrendFieldMapping(**item)) @@ -253,12 +287,12 @@ def create_sensitive_word(data: SensitiveWordCreate, db: Session = Depends(get_d return item @router.put("/sensitive-words/{item_id}", response_model=SensitiveWordResponse) -def update_sensitive_word(item_id: int, enabled: bool = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)): +def update_sensitive_word(item_id: int, body: SensitiveWordCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)): item = db.query(SensitiveWord).filter(SensitiveWord.id == item_id).first() if not item: raise HTTPException(status_code=404, detail="未找到") - if enabled is not None: - item.is_active = enabled + item.word = body.word + item.category = body.category db.commit() db.refresh(item) return item diff --git a/platform/backend/app/api/topics.py b/platform/backend/app/api/topics.py index dad7e35..42f3ba8 100644 --- a/platform/backend/app/api/topics.py +++ b/platform/backend/app/api/topics.py @@ -304,10 +304,17 @@ def batch_update_status( of = org_filter(current_user, Topic) if of is not True: q = q.filter(of) - updated = q.update( - {Topic.status: status, Topic.updated_at: datetime.now()}, - synchronize_session=False - ) + now = datetime.now() + update_dict = {Topic.status: status, Topic.updated_at: now} + if status in ('review', '待审查'): + update_dict[Topic.generated_at] = now + update_dict[Topic.reviewed_at] = now + elif status in ('ready', '待发布'): + update_dict[Topic.ready_at] = now.date() + update_dict[Topic.reviewed_at] = now + elif status in ('published', '已发布'): + update_dict[Topic.published_at] = now.date() + updated = q.update(update_dict, synchronize_session=False) db.commit() return {"ok": True, "updated": updated} diff --git a/platform/backend/app/core/generator.py b/platform/backend/app/core/generator.py index 138f9e2..09423fa 100644 --- a/platform/backend/app/core/generator.py +++ b/platform/backend/app/core/generator.py @@ -65,6 +65,7 @@ def run_creator_blocking(topic_id: str = None, timeout: int = 1800): topic = db.query(Topic).filter(Topic.id == topic_id).first() if topic: topic.generated_at = datetime.now(timezone.utc) + topic.reviewed_at = datetime.now(timezone.utc) if topic.status in ('pending', '待处理'): topic.status = 'review' db.commit() diff --git a/platform/backend/app/core/nvidia_client.py b/platform/backend/app/core/nvidia_client.py index 955d176..5fac04d 100644 --- a/platform/backend/app/core/nvidia_client.py +++ b/platform/backend/app/core/nvidia_client.py @@ -1,9 +1,11 @@ """ Unified LLM Client -支持 NVIDIA / opencode-go / 兼容 OpenAI 格式的 API,配置从环境变量读取 +支持 NVIDIA / sensenova / 兼容 OpenAI 格式的 API,配置从环境变量读取 +支持模型级 rate limit 和任务级模型选择(LLM_TASK_MODEL) """ import os +import time import requests import json import logging @@ -30,10 +32,28 @@ _API_KEYS = { # 代码级回退默认值(实际配置优先从 DB 读取) _FALLBACK = { "opencode-go": {"base_url": "https://opencode.ai/zen/go/v1", "model": "deepseek-v4-flash"}, - "nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.7-flash"}, + "nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.5-flash"}, "sensenova": {"base_url": "https://token.sensenova.cn/v1", "model": "deepseek-v4-flash"}, } +# 模型级 rate limiter(进程内,重启重置) +_RATE_LIMITER: Dict[str, List[float]] = {} + +def _check_rate_limit(provider: str, model: str, limit: int, window_minutes: int) -> bool: + if limit <= 0: + return True + key = f"{provider}/{model}" + now = time.time() + window_sec = window_minutes * 60 + timestamps = _RATE_LIMITER.get(key, []) + timestamps = [t for t in timestamps if now - t < window_sec] + _RATE_LIMITER[key] = timestamps + return len(timestamps) < limit + +def _record_usage(provider: str, model: str): + key = f"{provider}/{model}" + _RATE_LIMITER.setdefault(key, []).append(time.time()) + def _get_active_provider() -> str: """从 DB 读取活跃供应商,优先取 is_default=True;DB 不可用时回退环境变量""" try: @@ -61,6 +81,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict: db_model = None db_base_url = None db_api_key = None + db_rate_limit = 0 + db_rate_window = 300 try: from ..database import SessionLocal from ..models import LLMConfig @@ -70,6 +92,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict: db_model = cfg.model db_base_url = cfg.base_url db_api_key = cfg.api_key + db_rate_limit = cfg.rate_limit or 0 + db_rate_window = cfg.rate_limit_window_minutes or 300 db.close() except Exception: pass @@ -82,6 +106,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict: "api_key": api_key, "model": db_model or fb.get("model", ""), "base_url": db_base_url or fb.get("base_url", ""), + "rate_limit": db_rate_limit, + "rate_limit_window_minutes": db_rate_window, } def _get_db_defaults(provider: Optional[str] = None) -> dict: @@ -139,14 +165,25 @@ def call_llm( if not provider and os.getenv("LLM_TASK_PROVIDER"): task_provider = os.getenv("LLM_TASK_PROVIDER") providers_to_try = [task_provider] + [p for p in providers_to_try if p != task_provider] + # LLM_TASK_MODEL 环境变量可覆盖任务级别的模型选择 + if not model and os.getenv("LLM_TASK_MODEL"): + model = os.getenv("LLM_TASK_MODEL") last_error = None for p in providers_to_try: try: cfg = _get_provider_config(p) + actual_model = model or cfg["model"] + # Rate limit check + rl = cfg.get("rate_limit", 0) + rw = cfg.get("rate_limit_window_minutes", 300) + if not _check_rate_limit(p, actual_model, rl, rw): + logger.warning(f"[LLM] {p}/{actual_model} rate limit reached ({rl}/{rw}min) → 尝试下一个") + last_error = LLMError(f"{p}/{actual_model} rate limit reached") + continue endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions" headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"} payload = { - "model": model or cfg["model"], + "model": actual_model, "messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}], "temperature": temperature, "max_tokens": max_tokens, "top_p": top_p, "frequency_penalty": frequency_penalty, "presence_penalty": presence_penalty, @@ -176,6 +213,7 @@ def call_llm( if delta.get('content'): content_parts.append(delta['content']) if delta.get('reasoning_content'): reasoning_parts.append(delta['reasoning_content']) except Exception: continue + _record_usage(p, actual_model) return "".join(content_parts) or "".join(reasoning_parts) else: data = resp.json() @@ -186,6 +224,7 @@ def call_llm( if rc: parts = [p.strip() for p in rc.replace('\n', '。').split('。') if p.strip()] content = parts[-1] if parts else rc + _record_usage(p, actual_model) return content.strip() or '' except LLMError as e: if '429' in str(e): diff --git a/platform/backend/app/core/scheduler.py b/platform/backend/app/core/scheduler.py index 638ac4b..d34ed22 100644 --- a/platform/backend/app/core/scheduler.py +++ b/platform/backend/app/core/scheduler.py @@ -20,7 +20,7 @@ from .collector import run_collector_blocking logger = logging.getLogger(__name__) def _set_task_llm_provider(module_id: str): - """从 TaskConfig 读取 llm_provider 并设为环境变量,供子进程和 call_llm 读取""" + """从 TaskConfig 读取 llm_provider 和 llm_model 并设为环境变量,供子进程和 call_llm 读取""" try: from ..database import SessionLocal from ..models import TaskConfig @@ -32,10 +32,20 @@ def _set_task_llm_provider(module_id: str): if provider: os.environ["LLM_TASK_PROVIDER"] = provider logger.debug("[%s] LLM provider set to %s", module_id, provider) - return + else: + os.environ.pop("LLM_TASK_PROVIDER", None) + + model = cfg.params.get("llm_model") + if model: + os.environ["LLM_TASK_MODEL"] = model + logger.debug("[%s] LLM model set to %s", module_id, model) + else: + os.environ.pop("LLM_TASK_MODEL", None) + return except Exception: pass os.environ.pop("LLM_TASK_PROVIDER", None) + os.environ.pop("LLM_TASK_MODEL", None) MODULES = { "scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10"}, diff --git a/platform/backend/app/database.py b/platform/backend/app/database.py index 8da78ec..5c524b6 100644 --- a/platform/backend/app/database.py +++ b/platform/backend/app/database.py @@ -48,12 +48,15 @@ def init_db(): conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS base_url VARCHAR")) conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS api_key VARCHAR")) conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS is_default BOOLEAN DEFAULT FALSE")) + conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS rate_limit INTEGER DEFAULT 0")) + conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS rate_limit_window_minutes INTEGER DEFAULT 300")) try: conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images JSON DEFAULT '{}'::json")) except Exception: conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'")) for table, col, typ in [ ("users", "org_id", "VARCHAR DEFAULT 'default'"), + ("articles", "updated_at", "TIMESTAMP"), ("topics", "org_id", "VARCHAR DEFAULT 'default'"), ("topics", "reviewed_at", "TIMESTAMP"), ("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"), diff --git a/platform/backend/app/initial_data.py b/platform/backend/app/initial_data.py index 1181291..1fdc2a8 100644 --- a/platform/backend/app/initial_data.py +++ b/platform/backend/app/initial_data.py @@ -35,14 +35,26 @@ def import_initial_data(): db.commit() print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}") - # 补充或更新 LLM 供应商配置(nvidia 为主,opencode-go 为备) + # 补充或更新 LLM 供应商配置(nvidia 为主,sensenova 为备,含多模型) expected = { "opencode-go": dict(provider="opencode-go", model="deepseek-v4-flash", base_url="https://opencode.ai/zen/go/v1", temperature=0.7, max_tokens=131072, is_active=True, user_prompt_template="将以下内容扩展为完整文章:\n{topic_title}\n{core_concept}"), - "nvidia": dict(provider="nvidia", model="stepfun-ai/step-3.5-flash", + "nvidia": dict(provider="nvidia", model="qwen/qwen3.5-397b-a17b", base_url="https://integrate.api.nvidia.com/v1", temperature=0.5, max_tokens=131072, is_active=False, user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"), + "sensenova-6.7-flash-lite": dict(provider="sensenova", model="sensenova-6.7-flash-lite", + base_url="https://token.sensenova.cn/v1", temperature=0.3, max_tokens=16384, is_active=True, + rate_limit=1500, rate_limit_window_minutes=300, + user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"), + "sensenova-u1-fast": dict(provider="sensenova", model="sensenova-u1-fast", + base_url="https://token.sensenova.cn/v1", temperature=0.3, max_tokens=16384, is_active=True, + rate_limit=1500, rate_limit_window_minutes=300, + user_prompt_template="根据以下内容生成信息图:\n{section_content}"), + "sensenova-deepseek": dict(provider="sensenova", model="deepseek-v4-flash", + base_url="https://token.sensenova.cn/v1", temperature=0.3, max_tokens=16384, is_active=True, + rate_limit=500, rate_limit_window_minutes=300, + user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"), } existing = {c.name: c for c in db.query(LLMConfig).all()} for name, cfg in expected.items(): @@ -90,7 +102,7 @@ def import_initial_data(): "platform": "zhihu", "name": "知乎", "icon": "🔍", - "default_format": "长文深度分析,1500-3000字,有数据支撑", + "default_format": "深度分析 3000-8000字,数据驱动", "compliance_rules": { "max_title_len": 100, "allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"], @@ -102,14 +114,14 @@ def import_initial_data(): "image_count_max": 0, "image_width": 0, "image_height": 0, - "min_words": 1500, - "max_words": 3000 + "min_words": 3000, + "max_words": 8000 }, { "platform": "wechat", "name": "微信公众号", "icon": "💚", - "default_format": "公众号图文,800-1500字,亲切口语化", + "default_format": "个人叙事 2000-4000字,对话感", "compliance_rules": { "max_title_len": 32, "allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"], @@ -121,14 +133,14 @@ def import_initial_data(): "image_count_max": 3, "image_width": 1080, "image_height": 1080, - "min_words": 800, - "max_words": 1500 + "min_words": 2000, + "max_words": 4000 }, { "platform": "xiaohongshu", "name": "小红书", "icon": "📕", - "default_format": "图文笔记,300-800字,emoji+标签", + "default_format": "精炼干货 400-1000字,实用优先", "compliance_rules": { "max_title_len": 50, "allowed_tags": ["生活方式", "可持续", "AI", "个人成长", "极简", "环保"], @@ -140,14 +152,29 @@ def import_initial_data(): "image_count_max": 6, "image_width": 1080, "image_height": 1440, - "min_words": 300, - "max_words": 800 + "min_words": 400, + "max_words": 1000 } ] for p in platforms: db.add(PlatformConfig(**p)) db.commit() print("✅ 插入平台配置") + else: + # 更新已有平台配置的字数要求(迁移:2026-06 内容质量升级) + platform_updates = { + "zhihu": {"min_words": 3000, "max_words": 8000, "default_format": "深度分析 3000-8000字,数据驱动"}, + "wechat": {"min_words": 2000, "max_words": 4000, "default_format": "个人叙事 2000-4000字,对话感"}, + "xiaohongshu": {"min_words": 400, "max_words": 1000, "default_format": "精炼干货 400-1000字,实用优先"}, + } + for p in db.query(PlatformConfig).all(): + if p.platform in platform_updates: + up = platform_updates[p.platform] + p.min_words = up["min_words"] + p.max_words = up["max_words"] + p.default_format = up["default_format"] + db.commit() + print("✅ 平台配置已更新(字数/格式)") if db.query(TopicField).count() == 0: fields = [ diff --git a/platform/backend/app/models.py b/platform/backend/app/models.py index a6f3f94..7ecba21 100644 --- a/platform/backend/app/models.py +++ b/platform/backend/app/models.py @@ -288,7 +288,8 @@ class Article(Base): html_content = Column(Text) word_count = Column(Integer, nullable=True) outline = Column(Text, nullable=True) - images = Column(JSON, default=dict) # {"cover": "/path/to/cover.png", "chart": "/path/to/chart.png"} + images = Column(JSON, default=dict) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # {"cover": "/path/to/cover.png", "chart": "/path/to/chart.png"} class PublishRecord(Base): @@ -628,6 +629,8 @@ class LLMConfig(Base): api_key = Column(String, nullable=True) is_active = Column(Boolean, default=True) is_default = Column(Boolean, default=False) + rate_limit = Column(Integer, default=0, comment="每个时间窗口的调用上限,0=不限") + rate_limit_window_minutes = Column(Integer, default=300, comment="时间窗口(分钟),默认5小时") created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -645,6 +648,8 @@ class LLMConfig(Base): "api_key": f"{self.api_key[:8]}..." if self.api_key else None, "is_active": self.is_active, "is_default": self.is_default, + "rate_limit": self.rate_limit, + "rate_limit_window_minutes": self.rate_limit_window_minutes, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } diff --git a/platform/backend/app/schemas.py b/platform/backend/app/schemas.py index 5175f8e..bf9945f 100644 --- a/platform/backend/app/schemas.py +++ b/platform/backend/app/schemas.py @@ -523,6 +523,8 @@ class LLMConfigBase(BaseModel): api_key: Optional[str] = None is_active: bool = True is_default: bool = False + rate_limit: int = 0 + rate_limit_window_minutes: int = 300 class LLMConfigResponse(LLMConfigBase): diff --git a/platform/frontend/admin.html b/platform/frontend/admin.html index 824760a..3110863 100644 --- a/platform/frontend/admin.html +++ b/platform/frontend/admin.html @@ -13,7 +13,7 @@
-{{ row.pattern }}
+ {{ item.pattern }}