fix: 三平台内容差异化 + admin敏感词管理表格化
- writer.py: _expand_section() 去除 <100字阈值,始终调用 LLM 平台专属扩写
- prompt_loader.py: 新增 section_expansion_zhihu/wechat/xiaohongshu 三个独立 prompt
- admin.html: 配置管理标签页 + 敏感词/清理规则子标签 + 敏感词表格化管理(编辑/删除)
- config_items.py: PUT /sensitive-words/{id} 支持更新 word/category
- compliance_checker.py: AI 套话从 DB 加载 + 人称规则修正
- initial_data.py: PlatformConfig 字数迁移 + 新种子
- 各前端页面: LLM 配置 rate_limit 字段 + 供应商列表排序
This commit is contained in:
@@ -59,16 +59,40 @@ yu-zhi-ran/
|
|||||||
- `USE_POSTGRES=false` falls back to SQLite (used in tests)
|
- `USE_POSTGRES=false` falls back to SQLite (used in tests)
|
||||||
- Models have timezone-aware `DateTime(timezone=True)` columns
|
- 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)
|
### Prompts (`prompt_configs` table)
|
||||||
- **DB 是唯一来源**,修改 prompt 直接 `UPDATE prompt_configs SET content = '...' WHERE key = '...';`
|
- **DB 是唯一来源**,修改 prompt 直接 `UPDATE prompt_configs SET content = '...' WHERE key = '...';`
|
||||||
- 代码 `scripts/prompt_loader.py` 中的 `_PROMPT_DEFAULTS` **仅作种子数据**,第一次写入后就不再生效
|
- 代码 `scripts/prompt_loader.py` 中的 `_PROMPT_DEFAULTS` **仅作种子数据**,第一次写入后就不再生效
|
||||||
- 新增 prompt:在 `_PROMPT_DEFAULTS` 添加定义 → 重启后自动补入 DB(仅当该 key 不存在时)
|
- 新增 prompt:在 `_PROMPT_DEFAULTS` 添加定义 → 重启后自动补入 DB(仅当该 key 不存在时)
|
||||||
- 修改 prompt:**直接改 DB,不要改代码**(除非要更新种子供新环境用)
|
- 修改 prompt:**直接改 DB,不要改代码**(除非要更新种子供新环境用)
|
||||||
- DB 不可用时回退代码默认值(仅紧急模式)
|
- DB 不可用时回退代码默认值(仅紧急模式)
|
||||||
|
- 三平台独立 section_expansion 提示词 key: `section_expansion_zhihu` / `section_expansion_wechat` / `section_expansion_xiaohongshu`
|
||||||
|
- 标题提示词去套路化,使用自然语言(不像 AI 写的 prompt)
|
||||||
|
|
||||||
### Prompt quality checks (`compliance_checker.py`)
|
### Prompt quality checks (`compliance_checker.py`)
|
||||||
- 软质量问题(AI套话/人称混用/阅读体验)只降分、不挡流程(`passed=true`)
|
- 软质量问题(AI套话/人称混用/阅读体验)只降分、不挡流程(`passed=true`)
|
||||||
- 硬合规问题(敏感词/法律/品牌)扣分多且阻塞流程
|
- 硬合规问题(敏感词/法律/品牌)扣分多且阻塞流程
|
||||||
|
- AI 套话从 DB `content_clean_rules(rule_type='ai_telltale')` 加载,后台可动态管理
|
||||||
|
- 人称检查修正:去掉「大家」误报,仅检查「你们」和「你」混用
|
||||||
|
|
||||||
### LLM
|
### LLM
|
||||||
- `call_llm()` in `core/nvidia_client.py` — reads active provider from DB `LLMConfig.is_active`, API key from env
|
- `call_llm()` in `core/nvidia_client.py` — reads active provider from DB `LLMConfig.is_active`, API key from env
|
||||||
|
|||||||
+26
-2
@@ -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 |
|
| 搜索提供商重构 | 2026-05-28 | 移除 opencode MCP(配额耗尽),新增 360/搜狗/微信搜索(免 Key),百度千帆日限 50→200 |
|
||||||
| opencode 冗余代码清理 | 2026-06-02 | 删除 opencode_search.py / mcp_search_server.py / _call_mcp / 前后端所有 opencode/MCP 引用;禁用搜索缓存定时任务 |
|
| 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 |
|
| 任务抽屉产出记录优化 | 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 页 `<div id="app">` + `theme-modern.css` 的 `[v-cloak]` 规则,消除原始模板代码闪烁 |
|
||||||
|
| **sensenova 多模型 + 模型级 rate limit** | **2026-06-04** | `LLMConfig` 加 `rate_limit`/`rate_limit_window_minutes`;seed 3 模型:deepseek-v4-flash(500次/5h)、6.7-flash-lite(1500次/5h)、u1-fast(1500次/5h);进程内 rate limiter;`LLM_TASK_MODEL` 环境变量实现任务级模型选择;`ai_image_generator.py` 已固定用 `sensenova-u1-fast` |
|
||||||
|
|
||||||
### ⏳ 待办
|
### ⏳ 待办
|
||||||
|
|
||||||
@@ -180,7 +183,28 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、规范说明
|
## 七、内容质量升级(v19 · 2026-06-05)
|
||||||
|
|
||||||
|
### 三平台差异化内容架构
|
||||||
|
|
||||||
|
**背景**:此前三平台共用同一篇 markdown,仅做浅层适配(加 emoji/换人称),导致知乎缺深度、公众号缺情感、小红书过长。
|
||||||
|
|
||||||
|
**核心改动:**
|
||||||
|
1. **writer.py** — `generate_full_markdown()` → `generate_platform_markdown(platform)`,每平台调用独立的 `section_expansion_{platform}` prompt
|
||||||
|
- 知乎:`section_expansion_zhihu` — 数据分析深度,400-800字/节,「你」视角
|
||||||
|
- 公众号:`section_expansion_wechat` — 个人叙事对话感,300-500字/节,「我」口吻
|
||||||
|
- 小红书:`section_expansion_xiaohongshu` — 精炼干货,100-200字/节,直接给方法+可 emoji
|
||||||
|
- 去掉公众号中「我们→我→你」的强制替换
|
||||||
|
2. **prompt_loader.py** — 新增 3 个平台独立 section_expansion 提示词,标题提示词去套路化(去除「直接输出」「不要其他文字」等 AI 味指令)
|
||||||
|
3. **平台字数提升** — min/max 大幅上调(知乎 3000-8000, 公众号 2000-4000, 小红书 400-1000),存于 `platform_configs` 表,后台可改
|
||||||
|
4. **compliance_checker.py** — AI 套话从 DB `content_clean_rules(rule_type='ai_telltale')` 加载,后台可动态管理;人称规则去掉「大家」误报
|
||||||
|
5. **config_items.py** — DEFAULT_CONTENT_CLEAN_RULES 新增 30+ 条 ai_telltale 规则
|
||||||
|
6. **initial_data.py** — PlatformConfig 字数迁移(已有库也会更新)
|
||||||
|
7. **AGENTS.md** — 文档化三平台差异化架构
|
||||||
|
|
||||||
|
**验证**:33/33 测试通过
|
||||||
|
|
||||||
|
## 八、规范说明
|
||||||
|
|
||||||
### 规划文档
|
### 规划文档
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ def update_article_content(
|
|||||||
)
|
)
|
||||||
db.add(article)
|
db.add(article)
|
||||||
article.html_content = html_content
|
article.html_content = html_content
|
||||||
article.updated_at = datetime.now()
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "id": article_id}
|
return {"ok": True, "id": article_id}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,32 @@ DEFAULT_CONTENT_CLEAN_RULES = [
|
|||||||
{"rule_type": "verbosity", "pattern": r"^综上所述$", "description": "AI废话-综上所述", "sort_order": 14},
|
{"rule_type": "verbosity", "pattern": r"^综上所述$", "description": "AI废话-综上所述", "sort_order": 14},
|
||||||
{"rule_type": "verbosity", "pattern": r"^通过以上", "description": "AI废话-通过以上", "sort_order": 15},
|
{"rule_type": "verbosity", "pattern": r"^通过以上", "description": "AI废话-通过以上", "sort_order": 15},
|
||||||
{"rule_type": "html_thinking", "pattern": r"<p[^>]*>(好的|好的,|好[的,]|我来|让我|我将|我这就)", "description": "AI思考-HTML模式", "sort_order": 16},
|
{"rule_type": "html_thinking", "pattern": r"<p[^>]*>(好的|好的,|好[的,]|我来|让我|我将|我这就)", "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):
|
class KeywordDomainMapResponse(BaseModel):
|
||||||
@@ -193,6 +219,14 @@ def _ensure_defaults(db: Session):
|
|||||||
if db.query(ContentCleanRule).count() == 0:
|
if db.query(ContentCleanRule).count() == 0:
|
||||||
for item in DEFAULT_CONTENT_CLEAN_RULES:
|
for item in DEFAULT_CONTENT_CLEAN_RULES:
|
||||||
db.add(ContentCleanRule(**item))
|
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:
|
if db.query(TrendFieldMapping).count() == 0:
|
||||||
for item in DEFAULT_TREND_FIELD_MAP:
|
for item in DEFAULT_TREND_FIELD_MAP:
|
||||||
db.add(TrendFieldMapping(**item))
|
db.add(TrendFieldMapping(**item))
|
||||||
@@ -253,12 +287,12 @@ def create_sensitive_word(data: SensitiveWordCreate, db: Session = Depends(get_d
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
@router.put("/sensitive-words/{item_id}", response_model=SensitiveWordResponse)
|
@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()
|
item = db.query(SensitiveWord).filter(SensitiveWord.id == item_id).first()
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="未找到")
|
raise HTTPException(status_code=404, detail="未找到")
|
||||||
if enabled is not None:
|
item.word = body.word
|
||||||
item.is_active = enabled
|
item.category = body.category
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(item)
|
db.refresh(item)
|
||||||
return item
|
return item
|
||||||
|
|||||||
@@ -304,10 +304,17 @@ def batch_update_status(
|
|||||||
of = org_filter(current_user, Topic)
|
of = org_filter(current_user, Topic)
|
||||||
if of is not True:
|
if of is not True:
|
||||||
q = q.filter(of)
|
q = q.filter(of)
|
||||||
updated = q.update(
|
now = datetime.now()
|
||||||
{Topic.status: status, Topic.updated_at: datetime.now()},
|
update_dict = {Topic.status: status, Topic.updated_at: now}
|
||||||
synchronize_session=False
|
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()
|
db.commit()
|
||||||
return {"ok": True, "updated": updated}
|
return {"ok": True, "updated": updated}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
if topic:
|
if topic:
|
||||||
topic.generated_at = datetime.now(timezone.utc)
|
topic.generated_at = datetime.now(timezone.utc)
|
||||||
|
topic.reviewed_at = datetime.now(timezone.utc)
|
||||||
if topic.status in ('pending', '待处理'):
|
if topic.status in ('pending', '待处理'):
|
||||||
topic.status = 'review'
|
topic.status = 'review'
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Unified LLM Client
|
Unified LLM Client
|
||||||
支持 NVIDIA / opencode-go / 兼容 OpenAI 格式的 API,配置从环境变量读取
|
支持 NVIDIA / sensenova / 兼容 OpenAI 格式的 API,配置从环境变量读取
|
||||||
|
支持模型级 rate limit 和任务级模型选择(LLM_TASK_MODEL)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -30,10 +32,28 @@ _API_KEYS = {
|
|||||||
# 代码级回退默认值(实际配置优先从 DB 读取)
|
# 代码级回退默认值(实际配置优先从 DB 读取)
|
||||||
_FALLBACK = {
|
_FALLBACK = {
|
||||||
"opencode-go": {"base_url": "https://opencode.ai/zen/go/v1", "model": "deepseek-v4-flash"},
|
"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"},
|
"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:
|
def _get_active_provider() -> str:
|
||||||
"""从 DB 读取活跃供应商,优先取 is_default=True;DB 不可用时回退环境变量"""
|
"""从 DB 读取活跃供应商,优先取 is_default=True;DB 不可用时回退环境变量"""
|
||||||
try:
|
try:
|
||||||
@@ -61,6 +81,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict:
|
|||||||
db_model = None
|
db_model = None
|
||||||
db_base_url = None
|
db_base_url = None
|
||||||
db_api_key = None
|
db_api_key = None
|
||||||
|
db_rate_limit = 0
|
||||||
|
db_rate_window = 300
|
||||||
try:
|
try:
|
||||||
from ..database import SessionLocal
|
from ..database import SessionLocal
|
||||||
from ..models import LLMConfig
|
from ..models import LLMConfig
|
||||||
@@ -70,6 +92,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict:
|
|||||||
db_model = cfg.model
|
db_model = cfg.model
|
||||||
db_base_url = cfg.base_url
|
db_base_url = cfg.base_url
|
||||||
db_api_key = cfg.api_key
|
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()
|
db.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -82,6 +106,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict:
|
|||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
"model": db_model or fb.get("model", ""),
|
"model": db_model or fb.get("model", ""),
|
||||||
"base_url": db_base_url or fb.get("base_url", ""),
|
"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:
|
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"):
|
if not provider and os.getenv("LLM_TASK_PROVIDER"):
|
||||||
task_provider = 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]
|
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
|
last_error = None
|
||||||
for p in providers_to_try:
|
for p in providers_to_try:
|
||||||
try:
|
try:
|
||||||
cfg = _get_provider_config(p)
|
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"
|
endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions"
|
||||||
headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"}
|
||||||
payload = {
|
payload = {
|
||||||
"model": model or cfg["model"],
|
"model": actual_model,
|
||||||
"messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}],
|
"messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}],
|
||||||
"temperature": temperature, "max_tokens": max_tokens, "top_p": top_p,
|
"temperature": temperature, "max_tokens": max_tokens, "top_p": top_p,
|
||||||
"frequency_penalty": frequency_penalty, "presence_penalty": presence_penalty,
|
"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('content'): content_parts.append(delta['content'])
|
||||||
if delta.get('reasoning_content'): reasoning_parts.append(delta['reasoning_content'])
|
if delta.get('reasoning_content'): reasoning_parts.append(delta['reasoning_content'])
|
||||||
except Exception: continue
|
except Exception: continue
|
||||||
|
_record_usage(p, actual_model)
|
||||||
return "".join(content_parts) or "".join(reasoning_parts)
|
return "".join(content_parts) or "".join(reasoning_parts)
|
||||||
else:
|
else:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -186,6 +224,7 @@ def call_llm(
|
|||||||
if rc:
|
if rc:
|
||||||
parts = [p.strip() for p in rc.replace('\n', '。').split('。') if p.strip()]
|
parts = [p.strip() for p in rc.replace('\n', '。').split('。') if p.strip()]
|
||||||
content = parts[-1] if parts else rc
|
content = parts[-1] if parts else rc
|
||||||
|
_record_usage(p, actual_model)
|
||||||
return content.strip() or ''
|
return content.strip() or ''
|
||||||
except LLMError as e:
|
except LLMError as e:
|
||||||
if '429' in str(e):
|
if '429' in str(e):
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from .collector import run_collector_blocking
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def _set_task_llm_provider(module_id: str):
|
def _set_task_llm_provider(module_id: str):
|
||||||
"""从 TaskConfig 读取 llm_provider 并设为环境变量,供子进程和 call_llm 读取"""
|
"""从 TaskConfig 读取 llm_provider 和 llm_model 并设为环境变量,供子进程和 call_llm 读取"""
|
||||||
try:
|
try:
|
||||||
from ..database import SessionLocal
|
from ..database import SessionLocal
|
||||||
from ..models import TaskConfig
|
from ..models import TaskConfig
|
||||||
@@ -32,10 +32,20 @@ def _set_task_llm_provider(module_id: str):
|
|||||||
if provider:
|
if provider:
|
||||||
os.environ["LLM_TASK_PROVIDER"] = provider
|
os.environ["LLM_TASK_PROVIDER"] = provider
|
||||||
logger.debug("[%s] LLM provider set to %s", module_id, provider)
|
logger.debug("[%s] LLM provider set to %s", module_id, provider)
|
||||||
|
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
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
os.environ.pop("LLM_TASK_PROVIDER", None)
|
os.environ.pop("LLM_TASK_PROVIDER", None)
|
||||||
|
os.environ.pop("LLM_TASK_MODEL", None)
|
||||||
|
|
||||||
MODULES = {
|
MODULES = {
|
||||||
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10"},
|
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10"},
|
||||||
|
|||||||
@@ -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 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 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 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:
|
try:
|
||||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images JSON DEFAULT '{}'::json"))
|
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images JSON DEFAULT '{}'::json"))
|
||||||
except Exception:
|
except Exception:
|
||||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'"))
|
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'"))
|
||||||
for table, col, typ in [
|
for table, col, typ in [
|
||||||
("users", "org_id", "VARCHAR DEFAULT 'default'"),
|
("users", "org_id", "VARCHAR DEFAULT 'default'"),
|
||||||
|
("articles", "updated_at", "TIMESTAMP"),
|
||||||
("topics", "org_id", "VARCHAR DEFAULT 'default'"),
|
("topics", "org_id", "VARCHAR DEFAULT 'default'"),
|
||||||
("topics", "reviewed_at", "TIMESTAMP"),
|
("topics", "reviewed_at", "TIMESTAMP"),
|
||||||
("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"),
|
("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
|||||||
@@ -35,14 +35,26 @@ def import_initial_data():
|
|||||||
db.commit()
|
db.commit()
|
||||||
print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
|
print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
|
||||||
|
|
||||||
# 补充或更新 LLM 供应商配置(nvidia 为主,opencode-go 为备)
|
# 补充或更新 LLM 供应商配置(nvidia 为主,sensenova 为备,含多模型)
|
||||||
expected = {
|
expected = {
|
||||||
"opencode-go": dict(provider="opencode-go", model="deepseek-v4-flash",
|
"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,
|
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}"),
|
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,
|
base_url="https://integrate.api.nvidia.com/v1", temperature=0.5, max_tokens=131072, is_active=False,
|
||||||
user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"),
|
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()}
|
existing = {c.name: c for c in db.query(LLMConfig).all()}
|
||||||
for name, cfg in expected.items():
|
for name, cfg in expected.items():
|
||||||
@@ -90,7 +102,7 @@ def import_initial_data():
|
|||||||
"platform": "zhihu",
|
"platform": "zhihu",
|
||||||
"name": "知乎",
|
"name": "知乎",
|
||||||
"icon": "🔍",
|
"icon": "🔍",
|
||||||
"default_format": "长文深度分析,1500-3000字,有数据支撑",
|
"default_format": "深度分析 3000-8000字,数据驱动",
|
||||||
"compliance_rules": {
|
"compliance_rules": {
|
||||||
"max_title_len": 100,
|
"max_title_len": 100,
|
||||||
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"],
|
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"],
|
||||||
@@ -102,14 +114,14 @@ def import_initial_data():
|
|||||||
"image_count_max": 0,
|
"image_count_max": 0,
|
||||||
"image_width": 0,
|
"image_width": 0,
|
||||||
"image_height": 0,
|
"image_height": 0,
|
||||||
"min_words": 1500,
|
"min_words": 3000,
|
||||||
"max_words": 3000
|
"max_words": 8000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"platform": "wechat",
|
"platform": "wechat",
|
||||||
"name": "微信公众号",
|
"name": "微信公众号",
|
||||||
"icon": "💚",
|
"icon": "💚",
|
||||||
"default_format": "公众号图文,800-1500字,亲切口语化",
|
"default_format": "个人叙事 2000-4000字,对话感",
|
||||||
"compliance_rules": {
|
"compliance_rules": {
|
||||||
"max_title_len": 32,
|
"max_title_len": 32,
|
||||||
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"],
|
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"],
|
||||||
@@ -121,14 +133,14 @@ def import_initial_data():
|
|||||||
"image_count_max": 3,
|
"image_count_max": 3,
|
||||||
"image_width": 1080,
|
"image_width": 1080,
|
||||||
"image_height": 1080,
|
"image_height": 1080,
|
||||||
"min_words": 800,
|
"min_words": 2000,
|
||||||
"max_words": 1500
|
"max_words": 4000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"platform": "xiaohongshu",
|
"platform": "xiaohongshu",
|
||||||
"name": "小红书",
|
"name": "小红书",
|
||||||
"icon": "📕",
|
"icon": "📕",
|
||||||
"default_format": "图文笔记,300-800字,emoji+标签",
|
"default_format": "精炼干货 400-1000字,实用优先",
|
||||||
"compliance_rules": {
|
"compliance_rules": {
|
||||||
"max_title_len": 50,
|
"max_title_len": 50,
|
||||||
"allowed_tags": ["生活方式", "可持续", "AI", "个人成长", "极简", "环保"],
|
"allowed_tags": ["生活方式", "可持续", "AI", "个人成长", "极简", "环保"],
|
||||||
@@ -140,14 +152,29 @@ def import_initial_data():
|
|||||||
"image_count_max": 6,
|
"image_count_max": 6,
|
||||||
"image_width": 1080,
|
"image_width": 1080,
|
||||||
"image_height": 1440,
|
"image_height": 1440,
|
||||||
"min_words": 300,
|
"min_words": 400,
|
||||||
"max_words": 800
|
"max_words": 1000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
for p in platforms:
|
for p in platforms:
|
||||||
db.add(PlatformConfig(**p))
|
db.add(PlatformConfig(**p))
|
||||||
db.commit()
|
db.commit()
|
||||||
print("✅ 插入平台配置")
|
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:
|
if db.query(TopicField).count() == 0:
|
||||||
fields = [
|
fields = [
|
||||||
|
|||||||
@@ -288,7 +288,8 @@ class Article(Base):
|
|||||||
html_content = Column(Text)
|
html_content = Column(Text)
|
||||||
word_count = Column(Integer, nullable=True)
|
word_count = Column(Integer, nullable=True)
|
||||||
outline = Column(Text, 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):
|
class PublishRecord(Base):
|
||||||
@@ -628,6 +629,8 @@ class LLMConfig(Base):
|
|||||||
api_key = Column(String, nullable=True)
|
api_key = Column(String, nullable=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
is_default = Column(Boolean, default=False)
|
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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=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,
|
"api_key": f"{self.api_key[:8]}..." if self.api_key else None,
|
||||||
"is_active": self.is_active,
|
"is_active": self.is_active,
|
||||||
"is_default": self.is_default,
|
"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,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -523,6 +523,8 @@ class LLMConfigBase(BaseModel):
|
|||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
is_default: bool = False
|
is_default: bool = False
|
||||||
|
rate_limit: int = 0
|
||||||
|
rate_limit_window_minutes: int = 300
|
||||||
|
|
||||||
|
|
||||||
class LLMConfigResponse(LLMConfigBase):
|
class LLMConfigResponse(LLMConfigBase):
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<script src="uni-nav.js"></script>
|
<script src="uni-nav.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" v-cloak>
|
||||||
<uni-nav title="系统管理" :username="currentUser.username" :is-admin="isAdmin" current-page="admin" @navigate="redirectToPage" @logout="logout">
|
<uni-nav title="系统管理" :username="currentUser.username" :is-admin="isAdmin" current-page="admin" @navigate="redirectToPage" @logout="logout">
|
||||||
</uni-nav>
|
</uni-nav>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
|
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
|
||||||
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
|
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
|
||||||
<el-button size="default" :type="activeTab === 'searchproviders' ? 'primary' : ''" @click="switchTab('searchproviders')">搜索API</el-button>
|
<el-button size="default" :type="activeTab === 'searchproviders' ? 'primary' : ''" @click="switchTab('searchproviders')">搜索API</el-button>
|
||||||
|
<el-button size="default" :type="activeTab === 'configitems' ? 'primary' : ''" @click="switchTab('configitems')">配置管理</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,6 +137,9 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="temperature" label="温度" width="70"></el-table-column>
|
<el-table-column prop="temperature" label="温度" width="70"></el-table-column>
|
||||||
<el-table-column prop="max_tokens" label="最大Token" width="100"></el-table-column>
|
<el-table-column prop="max_tokens" label="最大Token" width="100"></el-table-column>
|
||||||
|
<el-table-column label="调用上限" width="110">
|
||||||
|
<template #default="scope">{{ scope.row.rate_limit ? scope.row.rate_limit + '次/' + (scope.row.rate_limit_window_minutes || 300) + '分' : '不限' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="is_active" label="状态" width="80">
|
<el-table-column prop="is_active" label="状态" width="80">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag v-if="scope.row.is_active" type="success" size="small" effect="dark">启用</el-tag>
|
<el-tag v-if="scope.row.is_active" type="success" size="small" effect="dark">启用</el-tag>
|
||||||
@@ -164,6 +168,7 @@
|
|||||||
<div class="card-row"><span class="card-label">供应商</span><span class="card-value">{{ item.provider }}</span></div>
|
<div class="card-row"><span class="card-label">供应商</span><span class="card-value">{{ item.provider }}</span></div>
|
||||||
<div class="card-row"><span class="card-label">模型</span><span class="card-value">{{ item.model }}</span></div>
|
<div class="card-row"><span class="card-label">模型</span><span class="card-value">{{ item.model }}</span></div>
|
||||||
<div class="card-row"><span class="card-label">温度/最大Token</span><span class="card-value">{{ item.temperature }} / {{ item.max_tokens }}</span></div>
|
<div class="card-row"><span class="card-label">温度/最大Token</span><span class="card-value">{{ item.temperature }} / {{ item.max_tokens }}</span></div>
|
||||||
|
<div class="card-row"><span class="card-label">调用上限</span><span class="card-value">{{ item.rate_limit ? item.rate_limit + '次/' + (item.rate_limit_window_minutes || 300) + '分' : '不限' }}</span></div>
|
||||||
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag v-if="item.is_active" type="success" size="small" effect="dark">当前使用</el-tag><el-tag v-else type="info" size="small">未启用</el-tag></span></div>
|
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag v-if="item.is_active" type="success" size="small" effect="dark">当前使用</el-tag><el-tag v-else type="info" size="small">未启用</el-tag></span></div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
|
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
|
||||||
@@ -601,19 +606,160 @@
|
|||||||
</div>
|
</div>
|
||||||
<template #footer><el-button @click="searchProviderTestVisible = false">关闭</el-button></template>
|
<template #footer><el-button @click="searchProviderTestVisible = false">关闭</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<div v-if="activeTab === 'configitems'">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-button size="default" :type="configSubTab === 'sensitive' ? 'primary' : ''" @click="switchConfigSubTab('sensitive')">🔒 敏感词</el-button>
|
||||||
|
<el-button size="default" :type="configSubTab === 'cleanrules' ? 'primary' : ''" @click="switchConfigSubTab('cleanrules')">🧹 清理规则</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="configSubTab === 'sensitive'">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" size="small" @click="showAddSensitiveWord">新增敏感词</el-button>
|
||||||
|
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ sensitiveWords.length }} 个</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="swLoading" class="card-loading">加载中...</div>
|
||||||
|
<template v-else-if="sensitiveWords.length === 0">
|
||||||
|
<div class="empty-state">
|
||||||
|
<el-icon style="font-size:48px;color:#c0c4cc;"><IconWarning /></el-icon>
|
||||||
|
<div class="empty-text">暂无敏感词</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-table :data="sensitiveWords" border stripe class="data-table" style="width:100%">
|
||||||
|
<el-table-column type="index" label="#" width="50"></el-table-column>
|
||||||
|
<el-table-column prop="word" label="敏感词" min-width="160"></el-table-column>
|
||||||
|
<el-table-column prop="category" label="类别" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small">{{ row.category || '未分类' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="editSensitiveWord(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="deleteSensitiveWord(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="card-list-mobile">
|
||||||
|
<div v-for="item in sensitiveWords" :key="item.id" class="card-item">
|
||||||
|
<div><el-tag size="small">{{ item.category || '未分类' }}</el-tag> <strong>{{ item.word }}</strong></div>
|
||||||
|
<div style="margin-top:4px;">
|
||||||
|
<el-button size="small" @click="editSensitiveWord(item)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="deleteSensitiveWord(item)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="configSubTab === 'cleanrules'">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" size="small" @click="showAddCleanRule">新增清理规则</el-button>
|
||||||
|
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ contentCleanRules.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="ccrLoading" class="card-loading">加载中...</div>
|
||||||
|
<template v-else-if="contentCleanRules.length === 0">
|
||||||
|
<div class="empty-state">
|
||||||
|
<el-icon style="font-size:48px;color:#c0c4cc;"><IconWarning /></el-icon>
|
||||||
|
<div class="empty-text">暂无清理规则</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-table :data="contentCleanRules" border stripe class="data-table" style="width:100%">
|
||||||
|
<el-table-column type="index" label="#" width="50"></el-table-column>
|
||||||
|
<el-table-column prop="rule_type" label="类型" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small">{{ row.rule_type }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="description" label="描述" min-width="140"></el-table-column>
|
||||||
|
<el-table-column prop="pattern" label="模式" min-width="220">
|
||||||
|
<template #default="{ row }"><code style="font-size:12px;word-break:break-all;">{{ row.pattern }}</code></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '启用' : '停用' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="editCleanRule(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="deleteCleanRule(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="card-list-mobile">
|
||||||
|
<div v-for="item in contentCleanRules" :key="item.id" class="card-item">
|
||||||
|
<div><el-tag size="small">{{ item.rule_type }}</el-tag> <code style="font-size:12px;">{{ item.pattern }}</code></div>
|
||||||
|
<div style="margin-top:4px;">{{ item.description }}</div>
|
||||||
|
<div style="margin-top:4px;">
|
||||||
|
<el-tag :type="item.is_active ? 'success' : 'info'" size="small">{{ item.is_active ? '启用' : '停用' }}</el-tag>
|
||||||
|
<el-button size="small" style="margin-left:8px;" @click="editCleanRule(item)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="deleteCleanRule(item)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="swDialogVisible" :title="editingSwId ? '编辑敏感词' : '新增敏感词'" width="450px" :close-on-click-modal="false">
|
||||||
|
<el-form :model="swForm" label-width="80px">
|
||||||
|
<el-form-item label="敏感词"><el-input v-model="swForm.word" placeholder="输入敏感词"/></el-form-item>
|
||||||
|
<el-form-item label="类别">
|
||||||
|
<el-select v-model="swForm.category" style="width:100%">
|
||||||
|
<el-option label="general" value="general" />
|
||||||
|
<el-option label="政治敏感" value="政治敏感" />
|
||||||
|
<el-option label="违禁内容" value="违禁内容" />
|
||||||
|
<el-option label="不实信息" value="不实信息" />
|
||||||
|
<el-option label="porn" value="porn" />
|
||||||
|
<el-option label="ad" value="ad" />
|
||||||
|
<el-option label="violence" value="violence" />
|
||||||
|
<el-option label="other" value="other" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="swDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveSensitiveWord" :loading="swSaving">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="ccrDialogVisible" :title="ccrDialogTitle" width="650px" :close-on-click-modal="false">
|
||||||
|
<el-form :model="ccrForm" label-width="100px">
|
||||||
|
<el-form-item label="类型">
|
||||||
|
<el-select v-model="ccrForm.rule_type" style="width:100%">
|
||||||
|
<el-option label="thinking (AI思考)" value="thinking" />
|
||||||
|
<el-option label="preface (AI前缀)" value="preface" />
|
||||||
|
<el-option label="verbosity (AI废话)" value="verbosity" />
|
||||||
|
<el-option label="ai_telltale (AI套话)" value="ai_telltale" />
|
||||||
|
<el-option label="html_thinking" value="html_thinking" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模式"><el-input v-model="ccrForm.pattern" placeholder="正则表达式"/></el-form-item>
|
||||||
|
<el-form-item label="描述"><el-input v-model="ccrForm.description" placeholder="简短说明"/></el-form-item>
|
||||||
|
<el-form-item label="排序"><el-input-number v-model="ccrForm.sort_order" :min="0" :max="999" style="width:100%"/></el-form-item>
|
||||||
|
<el-form-item label="启用"><el-switch v-model="ccrForm.is_active"/></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="ccrDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveCleanRule" :loading="ccrSaving">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="750px" :close-on-click-modal="false">
|
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="750px" :close-on-click-modal="false">
|
||||||
<el-form :model="llmConfigForm" label-width="110px">
|
<el-form :model="llmConfigForm" label-width="110px">
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name" placeholder="如:default_expand"/></el-form-item></el-col>
|
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name" placeholder="如:default_expand"/></el-form-item></el-col>
|
||||||
<el-col :span="12"><el-form-item label="供应商">
|
<el-col :span="12"><el-form-item label="供应商">
|
||||||
<el-select v-model="llmConfigForm.provider" style="width:100%">
|
<el-select v-model="llmConfigForm.provider" style="width:100%">
|
||||||
<el-option label="opencode-go (deepseek-v4-flash)" value="opencode-go"></el-option>
|
<el-option label="sensenova" value="sensenova"></el-option>
|
||||||
<el-option label="nvidia (step-3.7-flash)" value="nvidia"></el-option>
|
<el-option label="nvidia" value="nvidia"></el-option>
|
||||||
<el-option label="sensenova (deepseek-v4-flash)" value="sensenova"></el-option>
|
<el-option label="opencode-go" value="opencode-go"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item></el-col>
|
</el-form-item></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -630,6 +776,10 @@
|
|||||||
<el-col :span="6"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
|
<el-col :span="6"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
|
||||||
<el-col :span="6"><el-form-item label="默认"><el-switch v-model="llmConfigForm.is_default"/></el-form-item></el-col>
|
<el-col :span="6"><el-form-item label="默认"><el-switch v-model="llmConfigForm.is_default"/></el-form-item></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12"><el-form-item label="调用上限"><el-input-number v-model="llmConfigForm.rate_limit" :min="0" :max="999999" style="width:100%"/></el-form-item></el-col>
|
||||||
|
<el-col :span="12"><el-form-item label="时间窗口(分)"><el-input-number v-model="llmConfigForm.rate_limit_window_minutes" :min="1" :max="43200" style="width:100%"/></el-form-item></el-col>
|
||||||
|
</el-row>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="24"><el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt" :rows="3" placeholder="你是一个专业的内容创作助手。"/></el-form-item></el-col>
|
<el-col :span="24"><el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt" :rows="3" placeholder="你是一个专业的内容创作助手。"/></el-form-item></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -692,6 +842,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const activeTab = ref('users');
|
const activeTab = ref('users');
|
||||||
|
const configSubTab = ref('sensitive');
|
||||||
const currentUser = ref({ username: '' });
|
const currentUser = ref({ username: '' });
|
||||||
const isAdmin = ref(false);
|
const isAdmin = ref(false);
|
||||||
|
|
||||||
@@ -711,7 +862,7 @@ const llmConfigs = ref([]);
|
|||||||
const llmConfigsLoading = ref(false);
|
const llmConfigsLoading = ref(false);
|
||||||
const llmConfigDialogVisible = ref(false);
|
const llmConfigDialogVisible = ref(false);
|
||||||
const llmConfigDialogTitle = ref('新增 LLM 配置');
|
const llmConfigDialogTitle = ref('新增 LLM 配置');
|
||||||
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 131072, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true, is_default: false });
|
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 131072, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true, is_default: false, rate_limit: 0, rate_limit_window_minutes: 300 });
|
||||||
const editingLLMConfigId = ref(null);
|
const editingLLMConfigId = ref(null);
|
||||||
const loadLLMConfigs = async () => {
|
const loadLLMConfigs = async () => {
|
||||||
llmConfigsLoading.value = true;
|
llmConfigsLoading.value = true;
|
||||||
@@ -727,6 +878,7 @@ const llmConfigs = ref([]);
|
|||||||
llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'sensenova'; llmConfigForm.model = 'deepseek-v4-flash';
|
llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'sensenova'; llmConfigForm.model = 'deepseek-v4-flash';
|
||||||
llmConfigForm.base_url = 'https://token.sensenova.cn/v1'; llmConfigForm.api_key = ''; llmConfigForm.temperature = 0.3;
|
llmConfigForm.base_url = 'https://token.sensenova.cn/v1'; llmConfigForm.api_key = ''; llmConfigForm.temperature = 0.3;
|
||||||
llmConfigForm.max_tokens = 4000; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true; llmConfigForm.is_default = false;
|
llmConfigForm.max_tokens = 4000; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true; llmConfigForm.is_default = false;
|
||||||
|
llmConfigForm.rate_limit = 0; llmConfigForm.rate_limit_window_minutes = 300;
|
||||||
}
|
}
|
||||||
llmConfigDialogVisible.value = true;
|
llmConfigDialogVisible.value = true;
|
||||||
};
|
};
|
||||||
@@ -1078,6 +1230,87 @@ const llmConfigs = ref([]);
|
|||||||
catch (e) { ElMessage.error('重置失败: ' + e.message); }
|
catch (e) { ElMessage.error('重置失败: ' + e.message); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sensitiveWords = ref([]);
|
||||||
|
const swLoading = ref(false);
|
||||||
|
const swDialogVisible = ref(false);
|
||||||
|
const swSaving = ref(false);
|
||||||
|
const swForm = reactive({ word: '', category: 'general' });
|
||||||
|
const editingSwId = ref(null);
|
||||||
|
const loadSensitiveWords = async () => {
|
||||||
|
swLoading.value = true;
|
||||||
|
try { sensitiveWords.value = await api.get('/api/admin/config/sensitive-words'); }
|
||||||
|
catch (e) { console.error(e); }
|
||||||
|
finally { swLoading.value = false; }
|
||||||
|
};
|
||||||
|
const showAddSensitiveWord = () => { swForm.word = ''; swForm.category = 'general'; editingSwId.value = null; swDialogVisible.value = true; };
|
||||||
|
const editSensitiveWord = (row) => { swForm.word = row.word; swForm.category = row.category || 'general'; editingSwId.value = row.id; swDialogVisible.value = true; };
|
||||||
|
const saveSensitiveWord = async () => {
|
||||||
|
swSaving.value = true;
|
||||||
|
try {
|
||||||
|
const body = { word: swForm.word, category: swForm.category };
|
||||||
|
if (editingSwId.value) { await api.put('/api/admin/config/sensitive-words/' + editingSwId.value, body); ElMessage.success('更新成功'); }
|
||||||
|
else { await api.post('/api/admin/config/sensitive-words', body); ElMessage.success('添加成功'); }
|
||||||
|
swDialogVisible.value = false;
|
||||||
|
await loadSensitiveWords();
|
||||||
|
}
|
||||||
|
catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||||
|
finally { swSaving.value = false; }
|
||||||
|
};
|
||||||
|
const deleteSensitiveWord = async (row) => {
|
||||||
|
try { await ElMessageBox.confirm('确定删除「' + row.word + '」吗?', '提示', { type: 'warning' }); await api.delete('/api/admin/config/sensitive-words/' + row.id); ElMessage.success('删除成功'); await loadSensitiveWords(); }
|
||||||
|
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||||
|
};
|
||||||
|
const sensitiveWordsByCategory = computed(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const w of sensitiveWords.value) {
|
||||||
|
const cat = w.category || '未分类';
|
||||||
|
if (!map[cat]) map[cat] = [];
|
||||||
|
map[cat].push(w);
|
||||||
|
}
|
||||||
|
return Object.entries(map).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentCleanRules = ref([]);
|
||||||
|
const ccrLoading = ref(false);
|
||||||
|
const ccrDialogVisible = ref(false);
|
||||||
|
const ccrDialogTitle = ref('新增清理规则');
|
||||||
|
const ccrSaving = ref(false);
|
||||||
|
const ccrForm = reactive({ id: null, rule_type: 'ai_telltale', pattern: '', description: '', sort_order: 0, is_active: true });
|
||||||
|
const editingCcrId = ref(null);
|
||||||
|
const loadContentCleanRules = async () => {
|
||||||
|
ccrLoading.value = true;
|
||||||
|
try { contentCleanRules.value = await api.get('/api/admin/config/content-clean-rules'); }
|
||||||
|
catch (e) { console.error(e); }
|
||||||
|
finally { ccrLoading.value = false; }
|
||||||
|
};
|
||||||
|
const showAddCleanRule = () => {
|
||||||
|
ccrDialogTitle.value = '新增清理规则';
|
||||||
|
editingCcrId.value = null;
|
||||||
|
ccrForm.id = null; ccrForm.rule_type = 'ai_telltale'; ccrForm.pattern = ''; ccrForm.description = ''; ccrForm.sort_order = 0; ccrForm.is_active = true;
|
||||||
|
ccrDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
const editCleanRule = (row) => {
|
||||||
|
ccrDialogTitle.value = '编辑清理规则';
|
||||||
|
editingCcrId.value = row.id;
|
||||||
|
Object.assign(ccrForm, { id: row.id, rule_type: row.rule_type, pattern: row.pattern, description: row.description || '', sort_order: row.sort_order || 0, is_active: row.is_active });
|
||||||
|
ccrDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
const saveCleanRule = async () => {
|
||||||
|
ccrSaving.value = true;
|
||||||
|
try {
|
||||||
|
const body = { rule_type: ccrForm.rule_type, pattern: ccrForm.pattern, description: ccrForm.description, sort_order: ccrForm.sort_order, is_active: ccrForm.is_active };
|
||||||
|
if (editingCcrId.value) { await api.put('/api/admin/config/content-clean-rules/' + editingCcrId.value, body); ElMessage.success('更新成功'); }
|
||||||
|
else { await api.post('/api/admin/config/content-clean-rules', body); ElMessage.success('创建成功'); }
|
||||||
|
ccrDialogVisible.value = false;
|
||||||
|
await loadContentCleanRules();
|
||||||
|
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||||
|
finally { ccrSaving.value = false; }
|
||||||
|
};
|
||||||
|
const deleteCleanRule = async (row) => {
|
||||||
|
try { await ElMessageBox.confirm('确定删除该规则吗?', '提示', { type: 'warning' }); await api.delete('/api/admin/config/content-clean-rules/' + row.id); ElMessage.success('删除成功'); await loadContentCleanRules(); }
|
||||||
|
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr) => { if (!dateStr) return '-'; return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); };
|
const formatDate = (dateStr) => { if (!dateStr) return '-'; return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); };
|
||||||
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
|
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
|
||||||
|
|
||||||
@@ -1086,6 +1319,7 @@ const llmConfigs = ref([]);
|
|||||||
users: fetchUsers,
|
users: fetchUsers,
|
||||||
logs: loadLogTypes, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig,
|
logs: loadLogTypes, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig,
|
||||||
searchproviders: loadSearchProviders,
|
searchproviders: loadSearchProviders,
|
||||||
|
configitems: () => { loadSensitiveWords(); loadContentCleanRules(); },
|
||||||
};
|
};
|
||||||
const loadedTabs = new Set([]);
|
const loadedTabs = new Set([]);
|
||||||
|
|
||||||
@@ -1098,6 +1332,7 @@ const llmConfigs = ref([]);
|
|||||||
else console.warn('[switchTab] no loader for', name);
|
else console.warn('[switchTab] no loader for', name);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const switchConfigSubTab = (name) => { configSubTab.value = name; };
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const hash = window.location.hash.replace('#tab=', '');
|
const hash = window.location.hash.replace('#tab=', '');
|
||||||
@@ -1140,6 +1375,9 @@ const llmConfigs = ref([]);
|
|||||||
searchProviderTestVisible, searchProviderTesting, searchProviderTestResult,
|
searchProviderTestVisible, searchProviderTesting, searchProviderTestResult,
|
||||||
loadSearchProviders, addSearchProvider, editSearchProvider, saveSearchProvider, deleteSearchProvider, updateSearchProvider,
|
loadSearchProviders, addSearchProvider, editSearchProvider, saveSearchProvider, deleteSearchProvider, updateSearchProvider,
|
||||||
testSearchProvider, testAllSearchProviders, resetSearchUsage,
|
testSearchProvider, testAllSearchProviders, resetSearchUsage,
|
||||||
|
sensitiveWords, swLoading, swDialogVisible, swSaving, swForm, editingSwId, loadSensitiveWords, showAddSensitiveWord, editSensitiveWord, saveSensitiveWord, deleteSensitiveWord, configSubTab, switchConfigSubTab,
|
||||||
|
contentCleanRules, ccrLoading, ccrDialogVisible, ccrDialogTitle, ccrSaving, ccrForm, editingCcrId,
|
||||||
|
loadContentCleanRules, showAddCleanRule, editCleanRule, saveCleanRule, deleteCleanRule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<script src="uni-nav.js"></script>
|
<script src="uni-nav.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" v-cloak>
|
||||||
<uni-nav title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
|
<uni-nav title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
|
||||||
</uni-nav>
|
</uni-nav>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<script src="uni-nav.js"></script>
|
<script src="uni-nav.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" v-cloak>
|
||||||
<uni-nav title="素材库" :username="currentUser.username" :is-admin="isAdmin" current-page="assets" @navigate="redirectToPage" @logout="handleLogout">
|
<uni-nav title="素材库" :username="currentUser.username" :is-admin="isAdmin" current-page="assets" @navigate="redirectToPage" @logout="handleLogout">
|
||||||
</uni-nav>
|
</uni-nav>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
<script src="uni-nav.js"></script>
|
<script src="uni-nav.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" v-cloak>
|
||||||
<uni-nav title="内容日历" :username="currentUser.username" :is-admin="isAdmin" current-page="calendar" @navigate="redirectToPage" @logout="handleLogout">
|
<uni-nav title="内容日历" :username="currentUser.username" :is-admin="isAdmin" current-page="calendar" @navigate="redirectToPage" @logout="handleLogout">
|
||||||
</uni-nav>
|
</uni-nav>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<script src="uni-nav.js"></script>
|
<script src="uni-nav.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" v-cloak>
|
||||||
<uni-nav title="仪表盘" :username="currentUser.username" :is-admin="isAdmin" current-page="dashboard" @navigate="redirectToPage" @logout="handleLogout">
|
<uni-nav title="仪表盘" :username="currentUser.username" :is-admin="isAdmin" current-page="dashboard" @navigate="redirectToPage" @logout="handleLogout">
|
||||||
</uni-nav>
|
</uni-nav>
|
||||||
<div class="main-content" v-if="isLoggedIn">
|
<div class="main-content" v-if="isLoggedIn">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<script src="chart.umd.min.js"></script>
|
<script src="chart.umd.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" v-cloak>
|
||||||
<uni-nav title="数据分析" :username="currentUser.username" :is-admin="isAdmin" current-page="metrics" @navigate="redirectToPage" @logout="handleLogout">
|
<uni-nav title="数据分析" :username="currentUser.username" :is-admin="isAdmin" current-page="metrics" @navigate="redirectToPage" @logout="handleLogout">
|
||||||
</uni-nav>
|
</uni-nav>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<script src="uni-nav.js"></script>
|
<script src="uni-nav.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" v-cloak>
|
||||||
<uni-nav title="平台配置" :username="currentUser.username" :is-admin="isAdmin" current-page="platforms" @navigate="redirectToPage" @logout="handleLogout">
|
<uni-nav title="平台配置" :username="currentUser.username" :is-admin="isAdmin" current-page="platforms" @navigate="redirectToPage" @logout="handleLogout">
|
||||||
</uni-nav>
|
</uni-nav>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<script src="uni-nav.js"></script>
|
<script src="uni-nav.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" v-cloak>
|
||||||
<uni-nav title="任务管理" :username="currentUser.username" :is-admin="isAdmin" current-page="tasks" @navigate="redirectToPage" @logout="handleLogout">
|
<uni-nav title="任务管理" :username="currentUser.username" :is-admin="isAdmin" current-page="tasks" @navigate="redirectToPage" @logout="handleLogout">
|
||||||
</uni-nav>
|
</uni-nav>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
<div><span>最后运行</span><span>{{ mod.last_run || '从未' }}<span v-if="mod.last_status" :style="{marginLeft:'6px',padding:'1px 6px',borderRadius:'8px',fontSize:'11px',fontWeight:500}"><span v-if="mod.last_status==='success'" style="color:#67c23a;">✅成功</span><span v-else-if="mod.last_status==='failed'" style="color:#f56c6c;">❌失败</span><span v-else-if="mod.last_status==='running'" style="color:#e6a23c;">⏳运行中</span></span></span></div>
|
<div><span>最后运行</span><span>{{ mod.last_run || '从未' }}<span v-if="mod.last_status" :style="{marginLeft:'6px',padding:'1px 6px',borderRadius:'8px',fontSize:'11px',fontWeight:500}"><span v-if="mod.last_status==='success'" style="color:#67c23a;">✅成功</span><span v-else-if="mod.last_status==='failed'" style="color:#f56c6c;">❌失败</span><span v-else-if="mod.last_status==='running'" style="color:#e6a23c;">⏳运行中</span></span></span></div>
|
||||||
<div><span>下次运行</span><span>{{ mod.next_run || '—' }}</span></div>
|
<div><span>下次运行</span><span>{{ mod.next_run || '—' }}</span></div>
|
||||||
<div><span>累计运行</span><span>{{ mod.total_runs }} 次 <span style="color:#67c23a;">{{ mod.success_runs }} 成功</span> <span style="color:#f56c6c;">{{ mod.failed_runs }} 失败</span><span v-if="mod.running > 0" style="color:#e6a23c;"> {{ mod.running }} 运行中</span></span></div>
|
<div><span>累计运行</span><span>{{ mod.total_runs }} 次 <span style="color:#67c23a;">{{ mod.success_runs }} 成功</span> <span style="color:#f56c6c;">{{ mod.failed_runs }} 失败</span><span v-if="mod.running > 0" style="color:#e6a23c;"> {{ mod.running }} 运行中</span></span></div>
|
||||||
<div><span>LLM 模型</span><span>{{ (mod.params && mod.params.llm_provider) || (defaultLlmLabel || '默认') }}</span></div>
|
<div><span>LLM 模型</span><span>{{ llmLabel(mod) }}</span></div>
|
||||||
<div style="margin-top:10px; border-bottom:none;">
|
<div style="margin-top:10px; border-bottom:none;">
|
||||||
<el-button size="small" type="primary" @click.stop="triggerModule(mod.module_id)" :loading="runningModule === mod.module_id" :disabled="!mod.enabled">立即运行</el-button>
|
<el-button size="small" type="primary" @click.stop="triggerModule(mod.module_id)" :loading="runningModule === mod.module_id" :disabled="!mod.enabled">立即运行</el-button>
|
||||||
<el-button size="small" @click.stop="openModuleDetail(mod)">查看详情</el-button>
|
<el-button size="small" @click.stop="openModuleDetail(mod)">查看详情</el-button>
|
||||||
@@ -349,15 +349,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:16px;align-items:center;margin-bottom:12px;">
|
<div style="display:flex;gap:16px;align-items:center;margin-bottom:12px;">
|
||||||
<span style="font-size:13px;color:#606266;width:60px;">LLM</span>
|
<span style="font-size:13px;color:#606266;width:60px;">LLM</span>
|
||||||
<el-select v-model="drawerData.params.llm_provider" size="small" style="width:200px;" clearable placeholder="系统默认" @change="saveModuleConfig" :disabled="savingConfig">
|
<el-select v-model="drawerData.params.llm_provider" size="small" style="width:280px;" clearable placeholder="系统默认" @change="onLLMProviderChange" :disabled="savingConfig">
|
||||||
<el-option v-for="c in llmConfigs.filter(x=>x.is_active)" :key="c.id" :label="c.provider + ' (' + c.model + ')' + (c.is_default ? ' (默认)' : '')" :value="c.provider"/>
|
<el-option v-for="c in llmConfigs.filter(x=>x.is_active)" :key="c.id" :label="c.provider + '/' + c.model + (c.rate_limit ? ' (' + c.rate_limit + '次/' + (c.rate_limit_window_minutes||300) + '分)' : '') + (c.is_default ? ' (默认)' : '')" :value="c.provider"/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<span style="font-size:12px;color:#909399;">留空则使用系统默认模型</span>
|
<span style="font-size:12px;color:#909399;">留空则使用系统默认</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="Object.keys(drawerData.params || {}).filter(k => k !== 'llm_provider').length > 0">
|
<div v-if="Object.keys(drawerData.params || {}).filter(k => k !== 'llm_provider' && k !== 'llm_model').length > 0">
|
||||||
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">参数配置</div>
|
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">参数配置</div>
|
||||||
<div v-for="(val, key) in drawerData.params" v-if="key !== 'llm_provider'" :key="key" style="margin-bottom:12px;display:flex;align-items:center;gap:12px;">
|
<div v-for="(val, key) in drawerData.params" v-if="key !== 'llm_provider' && key !== 'llm_model'" :key="key" style="margin-bottom:12px;display:flex;align-items:center;gap:12px;">
|
||||||
<span style="font-size:13px;color:#606266;width:120px;">{{ key }}</span>
|
<span style="font-size:13px;color:#606266;width:120px;">{{ key }}</span>
|
||||||
<el-input v-if="typeof val === 'string'" v-model="drawerData.params[key]" size="small" style="flex:1;" @change="saveModuleConfig" :disabled="savingConfig"></el-input>
|
<el-input v-if="typeof val === 'string'" v-model="drawerData.params[key]" size="small" style="flex:1;" @change="saveModuleConfig" :disabled="savingConfig"></el-input>
|
||||||
<el-input-number v-else-if="typeof val === 'number'" v-model="drawerData.params[key]" size="small" :disabled="savingConfig" @change="saveModuleConfig"></el-input-number>
|
<el-input-number v-else-if="typeof val === 'number'" v-model="drawerData.params[key]" size="small" :disabled="savingConfig" @change="saveModuleConfig"></el-input-number>
|
||||||
@@ -672,9 +672,29 @@ const TasksApp = {
|
|||||||
const configs = await this.api('/api/admin/llmconfigs');
|
const configs = await this.api('/api/admin/llmconfigs');
|
||||||
this.llmConfigs = configs || [];
|
this.llmConfigs = configs || [];
|
||||||
const def = (configs || []).find(c => c.is_default);
|
const def = (configs || []).find(c => c.is_default);
|
||||||
this.defaultLlmLabel = def ? def.provider + ' (' + def.model + ')' : '';
|
this.defaultLlmLabel = def ? def.provider + '/' + def.model : '';
|
||||||
} catch (e) { console.error('loadLLMConfigs error:', e); this.llmConfigs = []; }
|
} catch (e) { console.error('loadLLMConfigs error:', e); this.llmConfigs = []; }
|
||||||
},
|
},
|
||||||
|
llmLabel(mod) {
|
||||||
|
const p = (mod.params && mod.params.llm_provider) || '';
|
||||||
|
const m = (mod.params && mod.params.llm_model) || '';
|
||||||
|
if (p && m) return p + '/' + m;
|
||||||
|
if (p) {
|
||||||
|
const match = (this.llmConfigs || []).find(c => c.provider === p && c.is_active);
|
||||||
|
if (match) return p + '/' + match.model;
|
||||||
|
}
|
||||||
|
return this.defaultLlmLabel || '默认';
|
||||||
|
},
|
||||||
|
onLLMProviderChange(val) {
|
||||||
|
if (!val) {
|
||||||
|
delete this.drawerData.params.llm_provider;
|
||||||
|
delete this.drawerData.params.llm_model;
|
||||||
|
} else {
|
||||||
|
const match = (this.llmConfigs || []).find(c => c.provider === val && c.is_active);
|
||||||
|
if (match) this.drawerData.params.llm_model = match.model;
|
||||||
|
}
|
||||||
|
this.saveModuleConfig();
|
||||||
|
},
|
||||||
async openModuleDetail(mod) {
|
async openModuleDetail(mod) {
|
||||||
this.showDrawer = true;
|
this.showDrawer = true;
|
||||||
this.drawerTitle = mod.title + ' 详情';
|
this.drawerTitle = mod.title + ' 详情';
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
Inspired by opencode.ai — clean, modern, responsive
|
Inspired by opencode.ai — clean, modern, responsive
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
|
/* ========== v-cloak (hide raw Vue templates) ========== */
|
||||||
|
[v-cloak] { display: none !important; }
|
||||||
|
|
||||||
/* ========== Design Tokens ========== */
|
/* ========== Design Tokens ========== */
|
||||||
:root {
|
:root {
|
||||||
/* Colors */
|
/* Colors */
|
||||||
@@ -116,7 +119,7 @@ body {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
min-height: calc(100vh - var(--nav-height));
|
min-height: calc(100vh - var(--nav-height));
|
||||||
}
|
}
|
||||||
.content-area { flex: 1; padding: var(--spacing-lg); overflow-y: auto; }
|
.content-area { flex: 1; padding: var(--spacing-md); overflow-y: auto; }
|
||||||
|
|
||||||
/* ========== Card ========== */
|
/* ========== Card ========== */
|
||||||
.card {
|
.card {
|
||||||
@@ -373,7 +376,7 @@ body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index
|
|||||||
|
|
||||||
/* ========== Responsive ========== */
|
/* ========== Responsive ========== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.content-area { padding: var(--spacing-md) !important; padding-bottom: 80px; }
|
.content-area { padding: var(--spacing-sm) !important; padding-bottom: 80px; }
|
||||||
.card { padding: var(--spacing-md) !important; }
|
.card { padding: var(--spacing-md) !important; }
|
||||||
.page-header { flex-direction: column; align-items: flex-start !important; gap: var(--spacing-sm) !important; }
|
.page-header { flex-direction: column; align-items: flex-start !important; gap: var(--spacing-sm) !important; }
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<script src="uni-nav.js"></script>
|
<script src="uni-nav.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" v-cloak>
|
||||||
<uni-nav title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
|
<uni-nav title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
|
||||||
</uni-nav>
|
</uni-nav>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
|
|||||||
@@ -55,10 +55,29 @@ AI_TELTALES = [
|
|||||||
"众所周知",
|
"众所周知",
|
||||||
"毋庸置疑",
|
"毋庸置疑",
|
||||||
"不知大家有没有发现",
|
"不知大家有没有发现",
|
||||||
|
"不可否认",
|
||||||
|
"毫无疑义",
|
||||||
|
"从某种意义上",
|
||||||
|
"从某种程度上",
|
||||||
|
"在一定程度上",
|
||||||
|
"换而言之",
|
||||||
|
"换言之",
|
||||||
|
"从本质",
|
||||||
|
"归根结底",
|
||||||
|
"说到底",
|
||||||
|
"这为我们提供了",
|
||||||
|
"为我们提供了宝贵的",
|
||||||
|
"引发了我们",
|
||||||
|
"不得不让人思考",
|
||||||
|
"引人深思",
|
||||||
|
"毫无悬念",
|
||||||
|
"毫无意外",
|
||||||
|
"毫无争议",
|
||||||
]
|
]
|
||||||
|
|
||||||
_cached_sensitive_words = None
|
_cached_sensitive_words = None
|
||||||
_cached_platform_rules = None
|
_cached_platform_rules = None
|
||||||
|
_cached_ai_telltales = None
|
||||||
|
|
||||||
def _load_sensitive_words():
|
def _load_sensitive_words():
|
||||||
global _cached_sensitive_words
|
global _cached_sensitive_words
|
||||||
@@ -121,6 +140,34 @@ def _load_platform_rules():
|
|||||||
_cached_platform_rules = PLATFORM_RULES
|
_cached_platform_rules = PLATFORM_RULES
|
||||||
return _cached_platform_rules
|
return _cached_platform_rules
|
||||||
|
|
||||||
|
|
||||||
|
def _load_ai_telltales():
|
||||||
|
"""从 DB ContentCleanRule 加载 AI 套话模式(rule_type='ai_telltale'),无 DB 时回退硬编码列表"""
|
||||||
|
global _cached_ai_telltales
|
||||||
|
if _cached_ai_telltales is not None:
|
||||||
|
return _cached_ai_telltales
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.core.prompt_loader import _get_session
|
||||||
|
from app.models import ContentCleanRule
|
||||||
|
session = _get_session()
|
||||||
|
try:
|
||||||
|
rows = session.query(ContentCleanRule).filter(
|
||||||
|
ContentCleanRule.rule_type == 'ai_telltale',
|
||||||
|
ContentCleanRule.is_active == True
|
||||||
|
).order_by(ContentCleanRule.sort_order).all()
|
||||||
|
if rows:
|
||||||
|
_cached_ai_telltales = [r.pattern for r in rows]
|
||||||
|
return _cached_ai_telltales
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_cached_ai_telltales = list(AI_TELTALES)
|
||||||
|
return _cached_ai_telltales
|
||||||
|
|
||||||
|
|
||||||
class ComplianceChecker:
|
class ComplianceChecker:
|
||||||
"""合规审查器"""
|
"""合规审查器"""
|
||||||
|
|
||||||
@@ -349,7 +396,8 @@ class ComplianceChecker:
|
|||||||
def _check_ai_telltales(self, text: str):
|
def _check_ai_telltales(self, text: str):
|
||||||
"""检查AI套话——正文中出现这些模式说明AI写作痕迹明显"""
|
"""检查AI套话——正文中出现这些模式说明AI写作痕迹明显"""
|
||||||
plain = re.sub(r'<[^>]+>', '', text)
|
plain = re.sub(r'<[^>]+>', '', text)
|
||||||
for pattern in AI_TELTALES:
|
patterns = _load_ai_telltales()
|
||||||
|
for pattern in patterns:
|
||||||
if re.search(pattern, plain):
|
if re.search(pattern, plain):
|
||||||
self.issues.append({
|
self.issues.append({
|
||||||
"type": "内容质量",
|
"type": "内容质量",
|
||||||
@@ -359,26 +407,16 @@ class ComplianceChecker:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _check_pronoun_consistency(self, text: str, platform: str):
|
def _check_pronoun_consistency(self, text: str, platform: str):
|
||||||
"""检查人称一致性(尤其是微信文章)"""
|
"""检查人称一致性"""
|
||||||
if platform != "wechat":
|
|
||||||
return
|
|
||||||
plain = re.sub(r'<[^>]+>', '', text)
|
plain = re.sub(r'<[^>]+>', '', text)
|
||||||
has_ni = '你' in plain
|
has_ni = '你' in plain
|
||||||
has_nimen = '你们' in plain
|
has_nimen = '你们' in plain
|
||||||
has_women = '我们' in plain
|
|
||||||
if has_nimen and has_ni:
|
if has_nimen and has_ni:
|
||||||
self.issues.append({
|
self.issues.append({
|
||||||
"type": "内容质量",
|
"type": "内容质量",
|
||||||
"category": "人称混用",
|
"category": "人称混用",
|
||||||
"detail": "微信文章中同时使用「你」和「你们」,建议统一为「你」",
|
"detail": "同时使用「你」和「你们」,建议统一为「你」",
|
||||||
"suggestion": "将所有「你们」替换为「你」,保持与读者的单数对话感"
|
"suggestion": "将「你们」替换为「你」,保持对话感"
|
||||||
})
|
|
||||||
if has_women and has_ni:
|
|
||||||
self.issues.append({
|
|
||||||
"type": "内容质量",
|
|
||||||
"category": "人称混用",
|
|
||||||
"detail": "微信文章中同时使用「我们」和「你」,建议统一视角",
|
|
||||||
"suggestion": "将「我们」替换为「你」或「我」,保持与读者对话而非说教"
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def _check_reading_experience(self, text: str, platform: str = ""):
|
def _check_reading_experience(self, text: str, platform: str = ""):
|
||||||
|
|||||||
@@ -89,8 +89,14 @@ def update_topic_status(topic_id: str, status: str, compliance_score: Optional[i
|
|||||||
topic.compliance_score = compliance_score
|
topic.compliance_score = compliance_score
|
||||||
if reviewed_at is not None:
|
if reviewed_at is not None:
|
||||||
topic.reviewed_at = reviewed_at
|
topic.reviewed_at = reviewed_at
|
||||||
if status in ['ready', 'published']:
|
if status == 'review' and topic.generated_at is None:
|
||||||
topic.generated_at = datetime.now()
|
topic.generated_at = datetime.now()
|
||||||
|
topic.reviewed_at = topic.reviewed_at or datetime.now()
|
||||||
|
if status == 'ready':
|
||||||
|
topic.ready_at = datetime.now().date()
|
||||||
|
topic.reviewed_at = topic.reviewed_at or datetime.now()
|
||||||
|
if status == 'published':
|
||||||
|
topic.published_at = datetime.now().date()
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
finally:
|
finally:
|
||||||
@@ -243,7 +249,6 @@ def save_article(topic_id: str, platform: str, html_content: str, db: Optional[S
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
if existing:
|
if existing:
|
||||||
existing.html_content = html_content
|
existing.html_content = html_content
|
||||||
existing.compliance_score = existing.compliance_score
|
|
||||||
else:
|
else:
|
||||||
article = Article(
|
article = Article(
|
||||||
id=article_id,
|
id=article_id,
|
||||||
|
|||||||
@@ -44,23 +44,33 @@ _PROMPT_DEFAULTS = {
|
|||||||
"temperature": 0.6, "max_tokens": 2000,
|
"temperature": 0.6, "max_tokens": 2000,
|
||||||
"variables": ["gaps"],
|
"variables": ["gaps"],
|
||||||
},
|
},
|
||||||
"section_expansion": {
|
"section_expansion_zhihu": {
|
||||||
"content": "你是一个资深作者,正在写一篇关于「{topic_title}」的文章。请写「{section_title}」这一节。\n\n今天日期:{date}。\n\n笔记要点:\n{content}\n\n输出要求(200-800字,3-6段):\n- 每段3-5句,段间空行分隔\n- 用「你」或「我」视角,自然口语化\n- 从具体场景或痛点切入,禁止以「引言」「核心观点」「总结」「开头钩子」这类标签开头\n- 每个论点配具体案例或数据,不要空谈道理\n- 回答读者「所以呢」——这对他有什么用\n\n格式:\n- 纯段落文字,不要标题/列表/标记(#、-、*、1.等)\n- 不要出现「首先其次最后」「总的来说」「值得注意的是」「综上所述」等AI套话\n- 来源URL请放在文末括号内",
|
"content": "写「{topic_title}」文章中「{section_title}」这一节。\n\n今天:{date}。\n笔记要点:\n{content}\n\n写400-800字,3-6段。每段围绕一个论点展开:先摆事实/数据,再分析原因,最后说清这跟读者有什么关系。\n\n注意:\n- 每个论点必须配具体数据或案例(至少一个),不要空谈。引用研究、报告或行业事件\n- 用「你」称呼读者,像是在知乎回答里跟人讨论\n- 从具体问题切入,不要以「引言/总结/核心观点」这类标签开头\n- 回答「所以呢」——这条信息对读者有什么用\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些表达\n- 来源放在文末括号内\n- 纯段落,不要列表/标题/序号标记",
|
||||||
"temperature": 0.75, "max_tokens": 3000,
|
"temperature": 0.78, "max_tokens": 4000,
|
||||||
|
"variables": ["topic_title", "section_title", "date", "content"],
|
||||||
|
},
|
||||||
|
"section_expansion_wechat": {
|
||||||
|
"content": "写「{topic_title}」文章中「{section_title}」这一节。\n\n今天:{date}。\n笔记要点:\n{content}\n\n写300-500字,用「我」的口吻叙述——像在跟朋友聊天,不是写文章。\n\n注意:\n- 从个人经历或感受切入,不要讲道理\n- 每个观点背后要有一个具体故事或观察\n- 段与段之间有情绪的起承转合,不是逻辑推理\n- 读起来像一个人在说话,而不是一篇文章在论述\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些套路\n- 纯段落,不要标题/列表/序号标记",
|
||||||
|
"temperature": 0.8, "max_tokens": 3000,
|
||||||
|
"variables": ["topic_title", "section_title", "date", "content"],
|
||||||
|
},
|
||||||
|
"section_expansion_xiaohongshu": {
|
||||||
|
"content": "写「{topic_title}」笔记中「{section_title}」这一节。\n\n今天:{date}。\n笔记要点:\n{content}\n\n写100-200字,1-3小段。每段1-2句,直接给干货。\n\n注意:\n- 直接给结论或方法,不要铺垫\n- 可以用emoji(1个/段,不要堆砌)\n- 像博主在分享亲测有效的经验\n- 不要讲道理,只讲「是什么」和「怎么做」\n- 用「你」或直接省略主语\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些套路\n- 纯段落,不要列表/标题/序号标记",
|
||||||
|
"temperature": 0.75, "max_tokens": 2000,
|
||||||
"variables": ["topic_title", "section_title", "date", "content"],
|
"variables": ["topic_title", "section_title", "date", "content"],
|
||||||
},
|
},
|
||||||
"title_optimize_zhihu": {
|
"title_optimize_zhihu": {
|
||||||
"content": "你是一个知乎高赞标题专家。为以下文章起3个标题。\n\n文章主题:{title}\n核心观点:{core}\n受众痛点:{pain}\n领域:{field}\n\n要求:\n- 信息密度高,包含搜索关键词\n- 优先使用数字、对比、悬念、痛点\n- 20字以内\n- 不要「如何...」开头\n- 有独特判断和立场,能引发讨论\n- 制造「不点开就亏了」的紧迫感\n\n直接输出3个标题,每行一个,不要序号和说明。",
|
"content": "你是知乎老用户,经常上热榜。给这篇文章起3个标题候选:\n\n文章主题:{title}\n核心观点:{core}\n受众痛点:{pain}\n领域:{field}\n\n感觉像在跟人推荐一篇值得辩论的文章。信息要密、20字内、有明确立场、让人想点进来说两句。不要「如何」开头。\n\n每行一个,不要编号。",
|
||||||
"temperature": 0.8, "max_tokens": 1500,
|
"temperature": 0.8, "max_tokens": 1500,
|
||||||
"variables": ["title", "core", "pain", "field"],
|
"variables": ["title", "core", "pain", "field"],
|
||||||
},
|
},
|
||||||
"title_optimize_wechat": {
|
"title_optimize_wechat": {
|
||||||
"content": "你是一个公众号标题专家。为以下文章起3个标题。\n\n文章主题:{title}\n核心观点:{core}\n\n要求:\n- 包含身份标签(如「打工人」「30岁后」「普通上班族」)\n- 包含情绪钩子(焦虑/反常识/后悔/稀缺)\n- 包含微信SEO关键词\n- 15-25字,口语化\n- 忌笼统,越具体越好\n- 不要感叹号堆砌\n\n参考公式:\n- 「身份+痛点+方案」:打工人学了一堆AI工具,为什么还在加班?\n- 「反常识+数据」:用了AI效率反而更低了?73%的人掉进了这个坑\n- 「结果+身份」:每天省出2小时后,我才发现自己以前有多傻\n\n直接输出3个标题,每行一个,不要序号和说明。",
|
"content": "你是公众号编辑。这篇文章面向{field}领域的读者,帮起3个标题:\n\n文章主题:{title}\n核心观点:{core}\n\n要有身份代入感(打工人/30岁+/普通上班族)、有情绪钩子(焦虑/反常识/后悔)、含微信搜索关键词。15-25字,口语化,越具体越好。参考这种风格:「用了AI效率反而更低了?73%的人掉进了这个坑」\n\n每行一个,不要编号。",
|
||||||
"temperature": 0.8, "max_tokens": 1500,
|
"temperature": 0.8, "max_tokens": 1500,
|
||||||
"variables": ["title", "core"],
|
"variables": ["title", "core", "field"],
|
||||||
},
|
},
|
||||||
"title_optimize_xhs": {
|
"title_optimize_xhs": {
|
||||||
"content": "你是一个小红书标题专家。为以下文章起3个标题。\n\n标题:{title}\n核心观点:{core}\n\n要求:\n- 18字以内\n- 爆款公式:身份/场景+数字+结果,或痛点+方案+反差\n- 包含小红书SEO关键词\n- 1个精确emoji(不要用🔥💥❌❓这几个滥用的)\n- 有场景感、结果感\n- 忌笼统:不要「必看/收藏/码住/绝了」\n\n直接输出3个标题,每行一个,不要序号和说明。",
|
"content": "你是小红书博主。给这篇笔记起3个标题:\n\n笔记主题:{title}\n核心:{core}\n\n每条18字以内,有具体场景和结果感。可以加1个精确emoji(不要🔥💥❌❓)。参考:「用了两年AI工具,推荐这5个就够了」这种风格。\n\n每行一个,不要编号。",
|
||||||
"temperature": 0.8, "max_tokens": 1500,
|
"temperature": 0.8, "max_tokens": 1500,
|
||||||
"variables": ["title", "core"],
|
"variables": ["title", "core"],
|
||||||
},
|
},
|
||||||
|
|||||||
+19
-19
@@ -43,9 +43,9 @@ logger = logging.getLogger(__name__)
|
|||||||
_md_parser = mistune.create_markdown()
|
_md_parser = mistune.create_markdown()
|
||||||
|
|
||||||
_FALLBACK_PLATFORM_CONFIG = {
|
_FALLBACK_PLATFORM_CONFIG = {
|
||||||
"zhihu": {"max_chars": 3000, "style": "深度长文分析", "min_chars": 1500},
|
"zhihu": {"max_chars": 8000, "style": "深度长文分析", "min_chars": 3000},
|
||||||
"wechat": {"max_chars": 1500, "style": "亲切口语化", "min_chars": 800},
|
"wechat": {"max_chars": 4000, "style": "个人叙事对话感", "min_chars": 2000},
|
||||||
"xiaohongshu": {"max_chars": 800, "style": "图文笔记,emoji+标签", "min_chars": 300},
|
"xiaohongshu": {"max_chars": 1000, "style": "图文笔记,精炼实用", "min_chars": 400},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _load_platform_config() -> dict:
|
def _load_platform_config() -> dict:
|
||||||
@@ -144,28 +144,30 @@ class Writer:
|
|||||||
bullet_count = sum(1 for l in lines if l.startswith(('- ', '* ', '**', '+ ')))
|
bullet_count = sum(1 for l in lines if l.startswith(('- ', '* ', '**', '+ ')))
|
||||||
return bullet_count / len(lines) > 0.4
|
return bullet_count / len(lines) > 0.4
|
||||||
|
|
||||||
def _expand_section(self, section: Dict) -> str:
|
def _expand_section(self, section: Dict, platform: str = "zhihu") -> str:
|
||||||
content = section.get('content', '').strip()
|
content = section.get('content', '').strip()
|
||||||
# 大纲要点格式(>40% 行以 -/*/** 开头)或内容过短(<100字)应由 LLM 展开为连贯段落
|
platform_prompt_key = f"section_expansion_{platform}"
|
||||||
if HAVE_LLM and (self._is_bullet_only(content) or (content and len(content) < 100)):
|
if platform not in ("zhihu", "wechat", "xiaohongshu"):
|
||||||
logger.info(f"使用 LLM 扩写章节(要点→段落): {section['title']}")
|
platform_prompt_key = "section_expansion_zhihu"
|
||||||
prompt = get_prompt("section_expansion",
|
|
||||||
|
if HAVE_LLM and content:
|
||||||
|
logger.info(f"LLM 扩写 [{platform}]: {section['title']} ({len(content)} chars)")
|
||||||
|
prompt = get_prompt(platform_prompt_key,
|
||||||
topic_title=self.topic['title'],
|
topic_title=self.topic['title'],
|
||||||
section_title=section['title'],
|
section_title=section['title'],
|
||||||
date=datetime.datetime.now().strftime('%Y年%m月%d日'),
|
date=datetime.datetime.now().strftime('%Y年%m月%d日'),
|
||||||
content=content,
|
content=content,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
params = get_prompt_params("section_expansion")
|
params = get_prompt_params(platform_prompt_key) or {"temperature": 0.75, "max_tokens": 3000}
|
||||||
expanded = call_llm(prompt, temperature=params.get("temperature", 0.75), max_tokens=params.get("max_tokens", 3000))
|
expanded = call_llm(prompt, temperature=params.get("temperature", 0.75), max_tokens=params.get("max_tokens", 3000))
|
||||||
if expanded:
|
if expanded:
|
||||||
cleaned = self._clean_markdown(expanded.strip())
|
cleaned = self._clean_markdown(expanded.strip())
|
||||||
if cleaned:
|
if cleaned:
|
||||||
return cleaned
|
return cleaned
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"LLM 扩写失败: {e}")
|
logger.warning(f"LLM 扩写失败 [{platform}]: {e}")
|
||||||
|
|
||||||
# Fallback: 将 bullet points 展开为段落(过滤噪音行)
|
|
||||||
lines = [l.strip() for l in content.split('\n') if not self._is_outline_noise(l)]
|
lines = [l.strip() for l in content.split('\n') if not self._is_outline_noise(l)]
|
||||||
if lines:
|
if lines:
|
||||||
sentences = []
|
sentences = []
|
||||||
@@ -185,17 +187,16 @@ class Writer:
|
|||||||
return self._clean_markdown(result)
|
return self._clean_markdown(result)
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def generate_full_markdown(self) -> str:
|
def generate_platform_markdown(self, platform: str = "zhihu") -> str:
|
||||||
sections = self._parse_outline_sections()
|
sections = self._parse_outline_sections()
|
||||||
parts = []
|
parts = []
|
||||||
for sec in sections:
|
for sec in sections:
|
||||||
if sec['level'] == 1:
|
if sec['level'] == 1:
|
||||||
if sec.get('content'):
|
if sec.get('content'):
|
||||||
expanded = self._expand_section(sec)
|
expanded = self._expand_section(sec, platform)
|
||||||
if expanded:
|
if expanded:
|
||||||
parts.append(expanded + "\n")
|
parts.append(expanded + "\n")
|
||||||
continue
|
continue
|
||||||
# 跳过大纲结构噪音节点
|
|
||||||
title_stripped = sec['title'].strip()
|
title_stripped = sec['title'].strip()
|
||||||
if title_stripped in ('文章大纲', '大纲', '文章结构', '结构'):
|
if title_stripped in ('文章大纲', '大纲', '文章结构', '结构'):
|
||||||
continue
|
continue
|
||||||
@@ -204,7 +205,7 @@ class Writer:
|
|||||||
heading = f"{'#' * sec['level']} {sec['title']}"
|
heading = f"{'#' * sec['level']} {sec['title']}"
|
||||||
parts.append(heading)
|
parts.append(heading)
|
||||||
if sec.get('content'):
|
if sec.get('content'):
|
||||||
expanded = self._expand_section(sec)
|
expanded = self._expand_section(sec, platform)
|
||||||
parts.append(expanded + "\n")
|
parts.append(expanded + "\n")
|
||||||
full_md = "\n".join(parts).strip()
|
full_md = "\n".join(parts).strip()
|
||||||
# 收集所有引用来源,统一添加到文末
|
# 收集所有引用来源,统一添加到文末
|
||||||
@@ -286,8 +287,6 @@ class Writer:
|
|||||||
if platform == "wechat":
|
if platform == "wechat":
|
||||||
result = []
|
result = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
# 人称统一:我们→我,你们→你
|
|
||||||
line = line.replace('我们', '我').replace('你们', '你').replace('我', '你')
|
|
||||||
if line.startswith('### '):
|
if line.startswith('### '):
|
||||||
result.append(f"\n**{line[4:]}**\n")
|
result.append(f"\n**{line[4:]}**\n")
|
||||||
elif line.startswith('## '):
|
elif line.startswith('## '):
|
||||||
@@ -370,6 +369,7 @@ class Writer:
|
|||||||
prompt = get_prompt("title_optimize_wechat",
|
prompt = get_prompt("title_optimize_wechat",
|
||||||
title=original,
|
title=original,
|
||||||
core=self.topic.get('core_concept', ''),
|
core=self.topic.get('core_concept', ''),
|
||||||
|
field=self.topic.get('field', ''),
|
||||||
)
|
)
|
||||||
elif platform == "xiaohongshu":
|
elif platform == "xiaohongshu":
|
||||||
prompt = get_prompt("title_optimize_xhs",
|
prompt = get_prompt("title_optimize_xhs",
|
||||||
@@ -459,10 +459,10 @@ class Writer:
|
|||||||
logger.info(f"选题 {self.topic_id} 状态已更新为待审查(数据库)")
|
logger.info(f"选题 {self.topic_id} 状态已更新为待审查(数据库)")
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
logger.info("开始撰写阶段")
|
logger.info("开始撰写阶段(三平台独立展开)")
|
||||||
markdown = self.generate_full_markdown()
|
|
||||||
results = {}
|
results = {}
|
||||||
for platform in ["zhihu", "wechat", "xiaohongshu"]:
|
for platform in ["zhihu", "wechat", "xiaohongshu"]:
|
||||||
|
markdown = self.generate_platform_markdown(platform)
|
||||||
html = self.generate_platform_html(markdown, platform)
|
html = self.generate_platform_html(markdown, platform)
|
||||||
results[platform] = str(self.save_html(html, platform))
|
results[platform] = str(self.save_html(html, platform))
|
||||||
self.mark_draft()
|
self.mark_draft()
|
||||||
|
|||||||
Reference in New Issue
Block a user