diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 10db949..5b9d220 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,7 +31,6 @@ jobs:
run: python tools/package_project_helper.py . /dev/null 0
- name: Run detect-secrets
- continue-on-error: true
run: |
if command -v detect-secrets &> /dev/null; then
detect-secrets scan --disable-plugin KeywordDetector \
diff --git a/.gitignore b/.gitignore
index 6e3f1f9..a7911a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,12 +8,14 @@ __pycache__/
.ruff_cache/
# runtime
+config/runtime_state.yaml
logs/
backups/
*.bak
*.tmp
chat/
-memory/pending_updates/
+memory/
+!memory/.gitkeep
# packaging
/release/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..1418625
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,113 @@
+# Changelog
+
+## v0.8.0 — 文档同步 + UI 中文标签 + 工程收口 / Docs sync & Chinese UI labels
+
+- **文档版本同步**:5 份文档(USER_GUIDE / PROJECT_PLAN / FUTURE / COMPREHENSIVE_PROJECT / changelog)版本升至 v0.8.0,模块描述与实际代码对齐
+- **UI 中文标签**:`constants.py` 模型/性能标签改为中文主导,`status_bar.py` 统计/性能行全中文显示,`chat_panel.py` 显示标签同步
+- 合并 v0.7.8 全部变更(性能预算系统、依赖锁定、状态模型文档化、CI 门禁升级、入口页新闻流程修复、README 重构、140 tests)
+
+## v0.7.8 — 性能预算 + 状态模型 + 工程收口
+
+- **性能预算系统**(新模块 `src/performance_budget.py`):所有 LLM 调用按 fast/standard/deep 三档分配 max_tokens 上限
+ - 主聊天:700 / 1100 / 1600
+ - 微信群聊回复:520 / 760 / 1050,历史行数按档自适应(16 / 28 / 40)
+ - 开场生成:420 / 620 / 850
+ - 新闻摘要:650 / 950 / 1300
+ - 新闻群聊讨论:520 / 760 / 1000
+- **依赖锁定**:迁移到 pip-tools 工作流(`requirements.in` → 锁定 `requirements.txt`)
+- **状态模型文档化**:`docs/STATE_MODEL.md` 定义真源层级、迁移指南、清理清单
+- **CI 门禁升级**:`detect-secrets` 改为硬门禁
+- **入口页新闻流程修复**:群聊未开始时也能显示新闻阶段面板,`_clear_wechat_news_state` 统一清理
+- **新闻讨论状态同步修复**:`run_discussion_stage` 正确调用 `update_wechat_join_state`
+- **README 重构**:展示型结构(Demo / 架构图 / 使用流程 / Roadmap)
+- **`.gitignore` 收口**:排除 `config/runtime_state.yaml`、`memory/`
+- **140 tests,Ruff clean**
+
+## v0.7.7 — 模块拆分与服务层解耦
+
+- **新闻链路拆分**:`wechat.py`(~550 行)拆分为 4 个专注模块 + 1 个兼容门面
+ - `wechat_format.py` — 纯文本 / 格式化工具
+ - `wechat_state.py` — 文件 I/O、群聊状态、生命周期管理
+ - `wechat_generator.py` — LLM 生成逻辑(群聊讨论、开场、互动回复)
+ - `wechat_prompt.py` — 系统 / 互动 prompt 加载(自包含,无外部依赖)
+ - `wechat.py` — 纯兼容门面,仅 re-export,零运行时逻辑
+- **服务层拆分**:`wechat_service.py` 改为直连子模块,绕开门面
+- **UI 逐阶段新闻流**:搜索 → 正文读取 → 生成摘要 → 群聊讨论,四步按钮推进
+- **自适应 max_articles**:fast→2、standard→4、deep→6
+- **安全加固**:
+ - 自定义 `_SafeHTTPRedirectHandler` 逐跳 SSRF 校验 + 最多 3 次重定向
+ - 包扫描增加 GitHub PAT、OpenRouter、通用 key/token 模式
+ - detect-secrets 加入 CI 流程
+- **内存保护**:Session logger 50 条自动 flush、2 小时老化警告
+- **防回归 guard**:`test_wechat_decoupling.py` 4 个测试确保模块边界不退化
+- **112 tests,Ruff clean**
+
+## v0.7.6 — 工程安全与新闻链路收口
+
+详细内容见 `changelog/README_v0_7_6.md`。
+
+## v0.7.5 — 文档同步收口 + 代码清理
+
+- 版本信息同步 10 个文件
+- 死代码清理(chat_stream / 重复函数)
+- YAML 同步 mtime canary 优化
+- 重复逻辑合并
+- 详见 `changelog/README_v0_7_5.md`
+
+## v0.7.4 — 工程体验收口
+
+- 自动化版本 bump 工具
+- LLM 配置文档化(`.env.example` 5 个 provider)
+- NewsRoundResult 结果对象化(覆盖率/警告/耗时)
+- UI 来源可信度展示
+- 详见 `changelog/README_v0_7_4.md`
+
+## v0.7.3 — 服务层拆分与工程化收口
+
+- Wechat news round 下沉到 `src/wechat_service.py`
+- session flush 批量提交
+- GitHub Actions CI
+- 架构级测试
+- LLM client 参数扩展
+- YAML runtime state 真源化
+- 详见 `changelog/README_v0_7_3.md`
+
+## v0.7.2 — 代码质量全面收口
+
+修复 4 个 Bug、性能优化(缓存/YAML/diff)、架构改善(常量去重/模块拆分)、Streamlit fragment 反模式修复、关键路径错误处理增强。
+
+## v0.7.1 — 搜索窗口与来源优化
+
+90 天搜索窗口、来源块压缩、摘要覆盖率提示、prompt 乱码修复、覆盖率统计对齐。
+
+## v0.7.0 — 多源新闻聚合
+
+多源 RSS 聚合、正文三层提取、来源块写入群聊、摘要边界约束、URL 安全检查。
+
+## v0.6.9 — 联网搜索增强
+
+自由文本联网搜索、进度指示、新闻缓存按 query 隔离、记忆写入加固。
+
+## v0.6.8 — 写入与缓存优化
+
+.tmp 排除、文件锁重试 finally 清理、LLM client 自动重建、wechat_state 批量写入、新闻 10 分钟缓存。
+
+## v0.6.7 — 刷新与健康检查
+
+刷新逻辑拆分、notice queue、健康检查只读化、打包排除增强、lru_cache 缓存。
+
+## v0.6.6 — 渲染与并发修复
+
+未知发言人渲染修复、并发写入安全、打包测试对齐。
+
+## v0.6.5 — 氛围与角色优化
+
+开场氛围选择、角色归一化兜底、侧栏与 memory 对齐、打包脚本拆分。
+
+## v0.6.4 — 首次可用正式包
+
+Catppuccin 暗色主题、紫蓝渐变、微信气泡 UI、路由系统、会话隔离、safe_writer。
+
+## v0.1 ~ v0.6.3 — 早期探索阶段
+
+界面仅纯文本、按钮触发全页刷新、侧边栏与主区域不同步、无视觉资源、自动路由缺失、群聊格式简陋。问题过多,未发布 Release。
diff --git a/COMPREHENSIVE_PROJECT.md b/COMPREHENSIVE_PROJECT.md
index 7147482..1d8dea2 100644
--- a/COMPREHENSIVE_PROJECT.md
+++ b/COMPREHENSIVE_PROJECT.md
@@ -1,7 +1,7 @@
# Study Agent 项目全貌
> 面向新协作者和无上下文接手者的当前阶段总览。
-> 当前开发阶段:`v0.7.5`
+> 当前开发阶段:`v0.8.0`
## 1. 项目定义
@@ -31,11 +31,16 @@
### 核心逻辑
-1. `src/wechat.py`: 微信群、搜索、页面读取、来源块、摘要
-2. `src/mode_manager.py`: 运行态模式和版本状态
-3. `src/session_logger.py`: 会话日志与安全写入
-4. `src/safe_writer.py`: 原子写入、备份、tmp 清理
-5. `src/llm_client.py`: LLM 调用与 client 重置
+1. `src/wechat_format.py`: 群聊文本格式化
+2. `src/wechat_state.py`: 群聊文件 I/O、状态管理
+3. `src/wechat_generator.py`: LLM 群聊生成(开场、互动回复、讨论)
+4. `src/wechat_prompt.py`: Prompt 模板加载
+5. `src/wechat_memory.py`: 群聊记忆提取
+6. `src/mode_manager.py`: 运行态模式和版本状态
+7. `src/session_logger.py`: 会话日志与安全写入
+8. `src/safe_writer.py`: 原子写入、备份、tmp 清理
+9. `src/llm_client.py`: LLM 调用与 client 重置
+10. `src/performance_budget.py`: 性能预算(max_tokens 分级)
### UI
@@ -45,7 +50,7 @@
### 文档
-1. `changelog/README_v0_7_1.md`: 当前版本说明
+1. `CHANGELOG.md`: 当前版本说明
2. `USER_GUIDE.md`: 当前使用指南
3. `PROJECT_PLAN.md`: 当前阶段规划
4. `FUTURE.md`: 下一阶段方向
@@ -76,8 +81,8 @@
如果你要快速接手,推荐顺序:
-1. `changelog/README_v0_7_1.md`
+1. `CHANGELOG.md`
2. `USER_GUIDE.md`
3. `PROJECT_PLAN.md`
-4. `src/wechat.py`
+4. `src/wechat_format.py`
5. `src/ui/wechat_panel.py`
diff --git a/FUTURE.md b/FUTURE.md
index d3a9685..6ea4ebf 100644
--- a/FUTURE.md
+++ b/FUTURE.md
@@ -1,31 +1,20 @@
# 未来方向
> 本文件记录当前版本之后的优先增强方向。
-> 当前开发阶段:`v0.7.4` 已落地,下一阶段主目标定位为 `v0.7.5`。
-
-## v0.7.4 完成情况(工程体验收口)
-
-v0.7.4 已发布,重点包括:
-
-1. 自动化版本管理工具 `tools/bump_version.py`,自动同步 7 个文件
-2. LLM 配置文档化:`.env.example` 扩展至 50 行,README 配置节分 4 小节
-3. NewsRoundResult 结果对象化:source_block / article_coverage / elapsed_ms / warnings
-4. UI 来源可信度展示:覆盖率条、逐条警告、条目图标区分
+> 当前开发阶段:`v0.8.0` 已落地,下一阶段主目标定位为 `v0.8.1`。
---
-## v0.7.5 重点
-
-### 1. 联网搜索质量提升
+## v0.7.9 重点
-### 1. 联网搜索质量提升
+### 1. UI 交互打磨
优先考虑:
-1. 进一步优化多源结果质量
-2. 更稳地区分"基于标题"与"基于页面文本"
-3. 对 Google News 中转页做更稳的处理
-4. 继续减少低质量页面文本混入
+1. 搜索按钮 cooldown
+2. 更明确的 running 状态反馈
+3. 上次搜索结果复用
+4. 网络失败 fallback 话题
### 2. 引用与来源体验
@@ -36,16 +25,7 @@ v0.7.4 已发布,重点包括:
3. 支持查看本轮来源详情
4. 支持手动清理搜索缓存
-### 3. 搜索交互体验
-
-优先考虑:
-
-1. 搜索按钮 cooldown
-2. 更明确的 running 状态
-3. 上次搜索结果复用
-4. 网络失败 fallback 话题
-
-### 4. 测试覆盖扩展
+### 3. 测试覆盖扩展
优先考虑:
diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md
index 80ea1dd..d889bc2 100644
--- a/PROJECT_PLAN.md
+++ b/PROJECT_PLAN.md
@@ -1,7 +1,7 @@
# Study Agent 项目规划
-> 当前活跃阶段:`v0.7.5`
-> 当前状态:v0.7.5 文档同步收口已完成,下一阶段 v0.7.6 回归功能增量(联网搜索质量、来源 UI、测试覆盖)。
+> 当前活跃阶段:`v0.8.0`
+> 当前状态:v0.8.0 已发布,基于 v0.7.8 的全量变更(性能预算、依赖锁定、状态模型、CI 门禁)追加文档版本同步与 UI 中文标签。
## 当前定位
@@ -53,19 +53,18 @@ v0.7.4 是一次工程体验收口:
3. NewsRoundResult 结果对象化(source_block / article_coverage / elapsed_ms / warnings)
4. UI 来源可信度展示(覆盖率条、警告逐条展示、条目图标区分)
-## v0.7.5 目标
+## v0.8.1 目标
-下一阶段回归功能增量:
+下一阶段回归 UI 打磨与稳定性:
-1. 联网搜索质量提升(多源结果优化、标题/正文区分增强)
-2. 引用与来源 UI 展示优化(折叠、链接可点击、来源详情)
-3. 搜索交互体验(按钮 cooldown、状态反馈、失败 fallback)
-4. 测试覆盖扩展(UI 模块、LLM 集成、写入操作)
-5. 会话状态无界增长防护
+1. UI 打磨(搜索按钮 cooldown、状态反馈、失败 fallback)
+2. 会话状态无界增长防护(session_state 上限 + TTL 淘汰)
+3. 测试覆盖扩展(UI 模块、LLM 集成、写入操作)
+4. 引用与来源体验优化(折叠、链接可点击、来源详情)
## 文档分工
-1. `changelog/README_v0_7_4.md`: 当前版本说明
+1. `CHANGELOG.md`: 当前版本说明
2. `USER_GUIDE.md`: 当前使用方法
3. `FUTURE.md`: 下一阶段方向
4. 历史 `README_v0_x.md`: 版本留档,不再作为当前状态依据
diff --git a/README.md b/README.md
index 58b39bf..ad2a7e5 100644
--- a/README.md
+++ b/README.md
@@ -3,229 +3,243 @@
+
-AI 学习搭子系统 —— 联网搜索 + 角色群聊 + 课后总结。
+**一个面向个人学习复盘的本地 AI 学习搭子系统** — 支持角色群聊、联网搜索、长期记忆和课后总结。
-## 功能
+> 不是又一个 AI 问答工具,而是一个会记住你学什么的 AI 学习伙伴。
-- **单人学习对话** — 与 AI 一对一讨论学习内容
-- **课后更新预览** — 总结学习进度,确认后写入记忆
-- **微信群互动** — 四位角色(三月七、刻晴、纳西妲、流萤)群聊讨论
-- **联网搜索** — 多源新闻聚合(Google News + Bing News + RSSHub),支持页面正文读取
-- **来源追溯** — 搜索结果写入群聊记录,可回溯依据
+---
+
+## 为什么做这个
+
+通用 AI 对话工具擅长回答问题,但不擅长「陪伴学习」:
+
+- 它们不记得你**昨天**学了什么、**上周**卡在了哪里
+- 它们不会主动帮你**总结**学习进展
+- 它们没有「角色感」—— 严肃还是轻松?鼓励还是挑战?全看随机
+
+Study Agent 的定位很明确:**一个运行在你本地的、有长期记忆的、有角色区分的 AI 学习搭子**。它会记住你的学习轨迹,在群聊中用不同角色和你讨论,课后自动总结进展,并把新的知识写进长期记忆。
+
+---
+
+## Demo
+
+> 截图待补充。运行 `streamlit run app.py` 后即可看到实际界面。
+
+| 界面 | 说明 |
+|------|------|
+| 主聊天 | 单人 AI 对话,支持 flash/pro 模型切换 |
+| 微信群聊 | 四位角色群聊,自动生成开场/互动回复 |
+| 联网搜索 | 多源新闻聚合(Google News + Bing + RSSHub)+ 正文提取 |
+| 课后总结 | 学习完成后自动总结,确认后写入长期记忆 |
+
+---
+
+## 使用流程
+
+```
+启动 App → 选择学习模式 (氛围/专注度)
+ │
+ ├── 单人对话 ──→ 提问/讨论 ──→ 课后总结 ──→ 记忆更新
+ │
+ └── 微信群聊 ──→ 生成开场 / 聊新闻 / 查资料
+ │
+ ┌────┴────┐
+ │ │
+ 联网搜索 角色互动讨论
+ │ │
+ 来源追溯写入 观点碰撞
+ │ │
+ └────┬────┘
+ │
+ 课后总结 → 确认 → 写入长期记忆
+```
+
+---
+
+## 核心功能
+
+| 功能 | 说明 |
+|------|------|
+| **单人对话** | 与 AI 一对一讨论学习内容,支持 flash/pro 模型切换 |
+| **角色群聊** | 四位角色(三月七、刻晴、纳西妲、流萤)群聊讨论,各有独立人设 |
+| **联网搜索** | Google News + Bing News + RSSHub 多源聚合,页面正文三层提取 |
+| **来源追溯** | 搜索结果写入群聊记录,可回溯依据 |
+| **课后总结** | 学习完成后自动总结进展,用户确认后写入记忆 |
+| **长期记忆** | 学习者画像、进度追踪、项目上下文、当前焦点,多级记忆档案 |
+| **多 Provider** | 支持 OpenAI / DeepSeek / OpenRouter / SiliconFlow / 本地模型 |
+| **氛围选择** | warm / close / standard 多种互动氛围切换 |
+
+---
+
+## 架构
+
+```
+streamlit run app.py
+ │
+┌──────┴──────┐
+│ app.py │ Streamlit 入口,路由到各 UI 面板
+└──────┬──────┘
+ │
+┌──────┴──────────────────────────────────────────┐
+│ src/ui/ │
+│ ├── main_panel.py 主页 │
+│ ├── chat_panel.py 对话面板 │
+│ ├── wechat_panel.py 微信群面板 │
+│ ├── after_session_panel.py 课后总结面板 │
+│ └── sidebar.py 侧边栏 │
+└──────┬──────────────────────────────────────────┘
+ │
+┌──────┴──────┬──────────────┬──────────────┬──────────────┐
+│ LLM Layer │ News Layer │ Memory │ WeChat │
+│ │ │ Layer │ Layer │
+│ llm_client │ news/ │ memory.py │ wechat_*.py │
+│ llm_router │ ├─rss_fetc │ memory_tools │ (format, │
+│ context_bui │ ├─article_e │ memory_writer│ state, │
+│ -ilder │ ├─link_reso │ │ generator, │
+│ │ ├─digest │ session_log │ prompt) │
+│ config.py │ └─article_f │ -ger │ │
+│ router.py │ etcher │ │ wechat_serv│
+│ │ │ │ -ice.py │
+└──────┬──────┴──────┬──────┴──────┬───────┴──────┬───────┘
+ │ │ │ │
+ .env.example chat/ memory/ roles/
+ (5 providers) (群聊记录) (记忆文件) (角色人设)
+```
+
+---
## 快速开始
```bash
-cd study-agent # 进入项目目录
+git clone study-agent
+cd study-agent
+cp .env.example .env
+# 编辑 .env,填入 API Key
+
+# 稳定安装(推荐,锁定版本)
pip install -r requirements.txt
pip install -r requirements-dev.txt
-cp .env.example .env
+
streamlit run app.py
```
浏览器打开 `http://localhost:8501`
-## 环境配置
-
-编辑 `.env`(完整模板见 `.env.example`):
+### 依赖管理
-### Provider 选择
+本项目使用 [pip-tools](https://github.com/jazzband/pip-tools) 管理依赖:
-通过 `LLM_PROVIDER_PROFILE` 切换 LLM 提供商,支持 `openai` / `deepseek` / `openrouter` / `siliconflow` / `local`。每个 provider 读写自己的环境变量:
+- [`requirements.in`](requirements.in) / [`requirements-dev.in`](requirements-dev.in) — **人类维护**,写范围版本
+- [`requirements.txt`](requirements.txt) / [`requirements-dev.txt`](requirements-dev.txt) — **自动生成**,写精确版本(lock 文件)
-| Provider | API Key | Base URL | 默认 Base URL |
-|---|---|---|---|
-| `deepseek` | `DEEPSEEK_API_KEY` | `DEEPSEEK_BASE_URL` | `https://api.deepseek.com/v1` |
-| `openrouter` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` |
-| `siliconflow` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL` | `https://api.siliconflow.cn/v1` |
-| `local` | `LOCAL_API_KEY` | `LOCAL_BASE_URL` | `http://127.0.0.1:8000/v1` |
-| `openai` | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | — |
+修改依赖后重新生成 lock 文件:
-每个 provider 的模型和连接参数:
-- `{PROVIDER}_MODEL_FLASH_NAME` — flash 模型名
-- `{PROVIDER}_MODEL_PRO_NAME` — pro 模型名
-- `{PROVIDER}_DEFAULT_MODEL_PROFILE` — 默认模型档位(`flash`/`pro`)
-- `{PROVIDER}_TIMEOUT_SECONDS` — 请求超时秒数
-- `{PROVIDER}_MAX_RETRIES` — 最大重试次数
-
-### 全局默认值
-
-未设置 provider 级参数时回退到以下变量:
-- `MODEL_FLASH_NAME` / `MODEL_PRO_NAME` — 模型名
-- `DEFAULT_MODEL_PROFILE` — 默认档位(默认 `flash`)
-- `LLM_TIMEOUT_SECONDS` — 全局超时(默认 `30`)
-- `LLM_MAX_RETRIES` — 全局最大重试(默认 `2`)
-- `LLM_MAX_TOKENS` — 全局最大 token 数
+```bash
+pip install pip-tools
+pip-compile requirements.in # 重新锁定主依赖
+pip-compile requirements-dev.in # 重新锁定开发依赖
+```
-### 任务级覆盖
+---
-内置任务(`llm_router` `after_session`)有硬编码默认值,可通过环境变量覆盖:
+## 环境配置
-| 任务 | 默认 max_tokens | 默认 timeout | 默认 temperature |
-|---|---|---|---|
-| `llm_router` | 240 | 20s | 0.0 |
-| `after_session` | 1200 | 45s | 0.3 |
+通过 `LLM_PROVIDER_PROFILE` 切换 LLM 提供商(`openai` / `deepseek` / `openrouter` / `siliconflow` / `local`),每个 provider 读写独立的环境变量:
-覆盖方式:`{TASK_KEY}_MAX_TOKENS` / `{TASK_KEY}_TIMEOUT_SECONDS` / `{TASK_KEY}_TEMPERATURE`(如 `AFTER_SESSION_MAX_TOKENS=1200`)。
+| Provider | 环境变量前缀 | 默认 Base URL |
+|----------|-------------|---------------|
+| `deepseek` | `DEEPSEEK_*` | `https://api.deepseek.com/v1` |
+| `openrouter` | `OPENROUTER_*` | `https://openrouter.ai/api/v1` |
+| `siliconflow` | `SILICONFLOW_*` | `https://api.siliconflow.cn/v1` |
+| `local` | `LOCAL_*` | `http://127.0.0.1:8000/v1` |
+| `openai` | `OPENAI_*` | — |
-### 解析链
+参数优先级:代码显式参数 → 任务级环境变量 → 任务默认值 → 全局环境变量 → provider 级环境变量。完整配置见 [`.env.example`](.env.example) 和 [用户指南](USER_GUIDE.md)。
-每个参数按以下优先级解析:
-1. 代码调用传入的显式参数
-2. 任务级环境变量(如 `AFTER_SESSION_MAX_TOKENS`)
-3. 任务硬编码默认值(`_TASK_DEFAULTS`)
-4. 全局环境变量(如 `LLM_MAX_TOKENS`)
-5. provider 级环境变量(如 `DEEPSEEK_TIMEOUT_SECONDS`)
+---
## 项目结构
```
├── app.py # Streamlit 入口
-├── requirements.txt # 运行依赖
-├── requirements-dev.txt # 开发/测试依赖
-├── .env.example # 环境变量模板
-├── USER_GUIDE.md # 用户指南
-│
├── src/
│ ├── llm_client.py # LLM 调用(chat / stream)
│ ├── llm_router.py # 模型路由分发
│ ├── context_builder.py # 上下文构建
-│ ├── mode_manager.py # 模式管理(性能/互动氛围)
+│ ├── mode_manager.py # 模式管理(版本/性能/氛围)
│ ├── role_manager.py # 角色加载与管理
-│ ├── session_logger.py # 会话日志
-│ ├── session_store.py # 会话持久化
-│ ├── safe_writer.py # 安全文件写入
-│ ├── backup_manager.py # 备份管理
-│ ├── wechat.py # 兼容门面(legacy re-exports)
-│ ├── wechat_format.py # 群聊文本格式化工具
-│ ├── wechat_state.py # 群聊 I/O、状态管理、生命周期
-│ ├── wechat_generator.py # LLM 生成逻辑
-│ ├── wechat_prompt.py # 系统/互动 prompt 加载
-│ ├── wechat_memory.py # 微信群记忆提取
+│ ├── performance_budget.py # 性能预算(max_tokens 分级)
│ ├── memory.py # 记忆系统
│ ├── memory_tools.py # 记忆工具
│ ├── memory_writer.py # 记忆写入
-│ ├── model_stats.py # 模型用量统计
-│ ├── perf.py # 性能日志
-│ ├── router.py # 路由配置
-│ ├── config.py # 全局配置
-│ ├── constants.py # 公共常量
-│ ├── text_utils.py # 文本工具
-│ ├── log_utils.py # 日志工具
-│ ├── health_check.py # 健康检查
-│ ├── export_tools.py # 导出工具
-│ ├── update_validator.py # 更新校验
+│ ├── wechat_format.py # 群聊文本格式化
+│ ├── wechat_state.py # 群聊 I/O、状态管理
+│ ├── wechat_generator.py # LLM 生成逻辑
+│ ├── wechat_prompt.py # Prompt 模板加载
+│ ├── wechat_memory.py # 群聊记忆提取
│ ├── after_session.py # 课后总结
-│ ├── news/
-│ │ ├── article_extractor.py # 文章正文提取(三层回退)
-│ │ ├── article_fetcher.py # 文章抓取(DNS/IP 安全校验 + SSRF 防御)
-│ │ ├── link_resolver.py # Google News 跳转链接解析
-│ │ ├── rss_fetcher.py # 多源 RSS 聚合与去重
-│ │ └── digest.py # 新闻摘要生成与来源块
-│ └── ui/
-│ ├── main_panel.py # 主页
-│ ├── chat_panel.py # 对话面板
-│ ├── sidebar.py # 侧边栏
-│ ├── wechat_panel.py # 微信群面板
-│ ├── wechat_bubble.py # 微信气泡渲染
-│ ├── after_session_panel.py # 课后总结面板
-│ ├── status_bar.py # 状态栏
-│ ├── session_state.py # 会话状态
-│ ├── theme.py # 主题
-│ └── avatar.py # 头像
-│
-├── tests/
-│ ├── test_wechat.py # 微信/新闻搜索测试
-│ ├── test_wechat_article_extract.py # 文章提取测试
-│ ├── test_wechat_service.py # 服务层测试
-│ ├── test_architecture_flows.py # 架构流测试
-│ ├── test_wechat_decoupling.py # 模块解耦 guard
-│ ├── test_after_session.py # 课后总结测试
-│ ├── test_v03_accept.py # 验收测试
-│ └── test_packaging_guards.py # 打包校验测试
-│
-├── chat/ # 群聊记录
-│ ├── wechat_group.md # 当前群聊
-│ ├── wechat_state.md # 群聊状态
-│ ├── wechat_unread.md # 未读消息
-│ └── archive/ # 历史群聊归档
-│
-├── memory/ # AI 记忆系统
-│ ├── learner_profile.md # 学习者画像
-│ ├── project_context.md # 项目上下文
-│ ├── progress.md # 学习进度
-│ ├── summary.md # 学习摘要
-│ ├── current_focus.md # 当前焦点
-│ ├── task_board.md # 任务看板
-│ ├── internal_state.md # 内部状态(版本号等)
-│ ├── system_detail.md # 系统详情
-│ ├── agent.md # Agent 配置
-│ └── pending_updates/ # 待确认更新
-│
-├── roles/ # 角色人设
-│ ├── march7.md # 三月七
-│ ├── keqing.md # 刻晴
-│ ├── nahida.md # 纳西妲
-│ ├── firefly.md # 流萤
-│ └── references/ # 角色背景资料
-│
-├── templates/ # Prompt 模板
-│ ├── wechat_update.md # 群聊生成
-│ ├── wechat_interactive_reply.md # 互动回复
-│ ├── wechat_memory_extract.md # 记忆提取
-│ └── routing_rules.md # 路由规则
-│
-├── config/
-│ └── routing_rules.yaml # 路由规则配置
-│
-├── tools/
-│ ├── package_project.ps1 # 打包脚本
-│ └── package_project_helper.py
-│
-├── assets/ # 静态资源(头像/背景/图标)
-│
-├── logs/ # 运行日志
-│
-└── backups/ # 备份文件
+│ ├── session_logger.py # 会话日志
+│ ├── config.py # 全局配置
+│ ├── router.py # 路由配置
+│ ├── news/ # 新闻聚合链路
+│ └── ui/ # Streamlit UI 组件
+├── tests/ # 140 个测试
+├── docs/ # 设计文档
+│ └── STATE_MODEL.md # 状态模型
+├── chat/ # 群聊记录
+├── memory/ # AI 长期记忆
+├── roles/ # 角色人设
+├── templates/ # Prompt 模板
+├── config/ # YAML 配置
+├── requirements.in # 依赖声明(范围版本)
+└── assets/ # 视觉资源
+```
+
+---
+
+## 测试
+
+```bash
+pytest tests/ -v # 140 tests
+pytest tests/ --cov=src # 覆盖率
+ruff check src/ tests/ # linting
```
+CI 通过 GitHub Actions 运行(每次 push 触发),集成 `detect-secrets` 扫描。
+
+---
+
## 版本历史
-- **v0.1 ~ v0.6.3** — 早期探索阶段。界面仅纯文本、按钮触发全页刷新、侧边栏与主区域不同步、无视觉资源、自动路由缺失、群聊格式简陋。问题过多,未发布 Release。
-- **v0.6.4** — 首次可用的正式包:Catppuccin 暗色主题、紫蓝渐变、微信气泡 UI、路由系统、会话隔离、safe_writer。
-- **v0.6.5** — 开场氛围选择、角色归一化兜底、侧栏与 memory 对齐、打包脚本拆分。
-- **v0.6.6** — 未知发言人渲染修复、并发写入安全、打包测试对齐。
-- **v0.6.7** — 刷新逻辑拆分、notice queue、健康检查只读化、打包排除增强、lru_cache 缓存。
-- **v0.6.8** — .tmp 排除、文件锁重试 finally 清理、LLM client 自动重建、wechat_state 批量写入、新闻 10 分钟缓存。
-- **v0.6.9** — 自由文本联网搜索、进度指示、新闻缓存按 query 隔离、记忆写入加固。
-- **v0.7.0** — 多源 RSS 聚合、正文三层提取、来源块写入群聊、摘要边界约束、URL 安全检查。
-- **v0.7.1** — 90 天搜索窗口、来源块压缩、摘要覆盖率提示、prompt 乱码修复、覆盖率统计对齐。
-- **v0.7.2** — 代码质量全面收口:修复 4 个 Bug、性能优化(缓存/YAML/diff)、架构改善(常量去重/模块拆分)、Streamlit fragment 反模式修复、关键路径错误处理增强。
-- **v0.7.3** — 服务层拆分与工程化收口:Wechat news round 下沉到 `src/wechat_service.py`、session flush 批量提交、GitHub Actions CI、架构级测试、LLM client 参数扩展、YAML runtime state 真源化。详见 `changelog/README_v0_7_3.md`。
-- **v0.7.4** — 工程体验收口:自动化版本 bump 工具、LLM 配置文档化(`.env.example` 5 个 provider)、NewsRoundResult 结果对象化(覆盖率/警告/耗时)、UI 来源可信度展示。详见 `changelog/README_v0_7_4.md`。
-- **v0.7.5** — 文档同步收口 + 代码清理:版本信息同步 10 个文件、死代码清理(chat_stream / 重复函数)、YAML 同步 mtime canary 优化、重复逻辑合并。详见 `changelog/README_v0_7_5.md`。
-- **v0.7.6** — 工程安全与新闻链路收口。详见 `changelog/README_v0_7_6.md`。
-- **v0.7.7** — 模块拆分与服务层解耦:
- - **新闻链路拆分**:`wechat.py`(~550 行)拆分为 4 个专注模块 + 1 个兼容门面
- - `wechat_format.py` — 纯文本 / 格式化工具
- - `wechat_state.py` — 文件 I/O、群聊状态、生命周期管理
- - `wechat_generator.py` — LLM 生成逻辑(群聊讨论、开场、互动回复)
- - `wechat_prompt.py` — 系统 / 互动 prompt 加载(自包含,无外部依赖)
- - `wechat.py` — 纯兼容门面,仅 re-export,零运行时逻辑
- - **服务层拆分**:`wechat_service.py` 改为直连子模块,绕开门面
- - **UI 逐阶段新闻流**:搜索 → 正文读取 → 生成摘要 → 群聊讨论,四步按钮推进
- - **自适应 max_articles**:fast→2、standard→4、deep→6
- - **安全加固**:
- - 自定义 `_SafeHTTPRedirectHandler` 逐跳 SSRF 校验 + 最多 3 次重定向
- - 包扫描增加 GitHub PAT、OpenRouter、通用 key/token 模式
- - detect-secrets 加入 CI 流程
- - **内存保护**:Session logger 50 条自动 flush、2 小时老化警告
- - **防回归 guard**:`test_wechat_decoupling.py` 4 个测试确保模块边界不退化
- - **112 tests,Ruff clean**
-
-完整 Release 及下载见 [Releases](https://github.com/2002yy/-study-agent/releases)。
+### v0.8.0 — 文档同步 + UI 中文标签 + 工程收口
+
+文档版本同步(5 份文档统一升级);UI 中文标签(模型/性能/状态栏全中文);合并性能预算系统、依赖锁定、状态模型文档化、CI 门禁升级、入口页新闻流程修复。**140 tests,Ruff clean**。
+
+### v0.7.8 — 性能预算 + 状态模型 + 工程收口
+
+### v0.7.7 — 模块拆分与服务层解耦
+
+新闻链路拆分为 4 个专注模块 + 兼容门面;服务层直连子模块;UI 逐阶段新闻流;SSRF 安全加固;Session logger 自动 flush 保护。**112 tests,Ruff clean**。
+
+### v0.7.6 — 工程安全与新闻链路收口
+
+完整历史见 [CHANGELOG.md](CHANGELOG.md)。
+
+---
+
+## Roadmap
+
+| 版本 | 方向 |
+|------|------|
+| v0.8.1 | 稳定性和 UI 打磨 |
+| v0.9 | 知识库 / RAG 能力 |
+| v0.10 | 多语言支持、导出增强 |
+| v1.0 | 插件化架构 + 自定义角色 |
+
+---
## 许可
diff --git a/USER_GUIDE.md b/USER_GUIDE.md
index a02866e..4128ff0 100644
--- a/USER_GUIDE.md
+++ b/USER_GUIDE.md
@@ -2,15 +2,16 @@
## 1. 当前阶段
-当前项目处于 `v0.7.5` 阶段,核心能力包括:
+当前项目处于 `v0.8.0` 阶段,核心能力包括:
1. 单人学习对话
2. 课后更新预览与确认写入
-3. 微信群互动
+3. 微信群互动(四位角色群聊)
4. 联网搜索与页面文本增强摘要
5. 多源新闻聚合与来源追溯
+6. 性能预算系统(fast/standard/deep 三级 max_tokens)
-历史版本 `README_v0_x.md` 主要用于留档;日常使用请优先参考本文件和 [changelog/README_v0_7_1.md](C:/Users/96967/Desktop/study%20agent/changelog/README_v0_7_1.md)。
+日常使用请优先参考本文件和 [v0.8.0 发布说明](changelog/README_v0_8_0.md)。
## 2. 启动
@@ -28,14 +29,14 @@ streamlit run app.py
复制 `.env.example` 为 `.env`,按文件内注释填写。`.env.example` 是唯一准配置,本指南不重复列另一套。
-当前 `.env.example` 默认项示意:
+配置以 `.env.example` 为准。当前推荐使用 Provider Profile 方式:
```text
-OPENAI_API_KEY=your_api_key_here
-OPENAI_BASE_URL=https://api.deepseek.com/v1
-MODEL_FLASH_NAME=deepseek-v4-flash
-MODEL_PRO_NAME=deepseek-v4-pro
-DEFAULT_MODEL_PROFILE=pro
+LLM_PROVIDER_PROFILE=deepseek
+DEEPSEEK_API_KEY=your_api_key_here
+DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
+DEEPSEEK_MODEL_FLASH_NAME=deepseek-chat
+DEEPSEEK_MODEL_PRO_NAME=deepseek-reasoner
```
常用建议:
diff --git a/changelog/README_v0_7_7.md b/changelog/README_v0_7_7.md
index ea8dafb..4582e9f 100644
--- a/changelog/README_v0_7_7.md
+++ b/changelog/README_v0_7_7.md
@@ -1,4 +1,4 @@
-# Study Agent v0.7.7 release notes
+# Study Agent v0.7.7 发布说明 / release notes
> 模块拆分收口版。wechat.py 从 ~1243 行拆为 6 个独立模块,保留兼容层,无功能变更。
@@ -10,7 +10,7 @@
| 模块 | 职责 | 行数 |
|---|---|---|
-| `src/wechat.py` | 兼容层 + 群聊生命周期、互动回复、开场生成、搜索摘要 | ~540 |
+| `src/wechat.py` | 纯兼容门面,零运行时逻辑,所有接口 re-export | ~100 |
| `src/wechat_format.py` | 纯文本/格式化工具(角色块解析、兜底文案) | ~95 |
| `src/news/link_resolver.py` | Google News 跳转链接解析 | ~90 |
| `src/news/article_fetcher.py` | 正文抓取 + DNS/IP 安全校验 | ~180 |
diff --git a/changelog/README_v0_8_0.md b/changelog/README_v0_8_0.md
new file mode 100644
index 0000000..873d091
--- /dev/null
+++ b/changelog/README_v0_8_0.md
@@ -0,0 +1,90 @@
+# Study Agent v0.8.0 发布说明 / Release Notes
+
+> 文档同步 + UI 中文标签 + 工程收口。合并 v0.7.8 全量变更与本轮文档/UI 统一。
+
+---
+
+## v0.8.0 新增
+
+### 1. 文档版本同步
+
+5 份文档统一升至 v0.8.0:
+
+- `USER_GUIDE.md`:版本号、配置示例(Provider Profile)、能力清单更新
+- `PROJECT_PLAN.md` / `FUTURE.md` / `COMPREHENSIVE_PROJECT.md`:版本升级、模块描述与实际代码对齐(wechat.py 拆分为 5 个子模块)、文档引用路径修正
+- `changelog/README_v0_7_7.md`:标题中英双语、wechat.py 行数/职责修正
+
+### 2. UI 中文标签
+
+用户可见文案统一为中文主导:
+
+- 模型标签:`自动` / `Flash(快速)` / `Pro(高质量)`
+- 性能模式:`快速` / `标准` / `深度`
+- 状态栏统计:`Flash 调用` / `路由调用` / `上次延迟` / `估算成本`
+- Debug 性能行:`性能: 路由 0.xxx | 记忆 0.xxx | 上下文 0.xxx | ...`
+
+### 3. 语言规范确立
+
+仓库采用中文主导 + 英文技术名保留的策略:
+
+| 类型 | 语言 |
+|------|------|
+| README / CHANGELOG | 中文主导,标题中英双语 |
+| 用户文档 | 全中文 |
+| UI 文案 | 中文 |
+| 代码/配置变量/函数名 | 保留英文 |
+
+---
+
+## 合并 v0.7.8 变更
+
+以下变更在开发分支上累积,与 v0.8.0 一并发布:
+
+### 性能预算系统
+
+新模块 `src/performance_budget.py`,按 fast/standard/deep 三档分配 max_tokens:
+
+- 主聊天:700 / 1100 / 1600
+- 群聊回复:520 / 760 / 1050,历史行数自适应(16 / 28 / 40)
+- 开场生成:420 / 620 / 850
+- 新闻摘要:650 / 950 / 1300
+- 新闻讨论:520 / 760 / 1000
+
+### 依赖锁定
+
+迁移至 pip-tools 工作流:`requirements.in` → `requirements.txt`(锁定精确版本)。
+
+### 状态模型文档化
+
+`docs/STATE_MODEL.md`:真源层级、迁移指南、清理清单。YAML 为真源,MD 文件为视图。
+
+### CI 门禁升级
+
+`detect-secrets` 改为硬门禁(移除 `continue-on-error`)。
+
+### 入口页新闻流程修复
+
+- 群聊未开始时也能显示新闻阶段面板
+- `_clear_wechat_news_state` 统一清理
+- `run_discussion_stage` 正确调用 `update_wechat_join_state`
+
+### README 重构
+
+展示型结构:产品定位 / Demo / 使用流程 / 架构图 / Roadmap。
+
+### `.gitignore` 收口
+
+排除 `config/runtime_state.yaml`、`memory/`。
+
+### 测试
+
+新增 28 个测试,总测试数 140,Ruff clean。
+
+---
+
+## 兼容性
+
+- 无新增依赖
+- 140 测试通过
+- Ruff clean
+- 所有对外接口保持向后兼容
diff --git a/config/runtime_state.yaml b/config/runtime_state.yaml
index f657bef..b85037c 100644
--- a/config/runtime_state.yaml
+++ b/config/runtime_state.yaml
@@ -1,7 +1,7 @@
version:
- current: v0.7.7
- next: v0.7.8
- active_task: post-v0.7.7 stabilization and UI polish
+ current: v0.8.0
+ next: v0.8.1
+ active_task: 文档同步 + UI 中文标签 + 性能预算 + 状态模型 + 工程收口
runtime:
entry_mode: wechat
performance_mode: standard
@@ -12,7 +12,7 @@ runtime:
interaction:
relationship_mode: close
wechat:
- mode: unread_feedback
+ mode: interactive_group
user_has_joined_group: false
first_join_reaction_done: false
memory_capture_enabled: true
diff --git a/docs/STATE_MODEL.md b/docs/STATE_MODEL.md
new file mode 100644
index 0000000..f28e04a
--- /dev/null
+++ b/docs/STATE_MODEL.md
@@ -0,0 +1,321 @@
+# 状态模型
+
+> 本文档定义 Study Agent 中**每个文件**的角色归属:哪些是源码、哪些是用户数据、哪些是缓存、哪些可以删除、哪些换电脑要迁移。
+
+---
+
+## 1. 总览
+
+```
+项目根目录
+├── 源码(进 Git,只读不改)
+│ ├── app.py, src/*.py, tests/*.py
+│ ├── config/routing_rules.yaml
+│ ├── .env.example, requirements*.txt
+│ ├── README.md, CHANGELOG.md, USER_GUIDE.md
+│ ├── roles/*.md, templates/*.md
+│ └── tools/*.ps1, tools/*.py
+│
+├── 配置模板(进 Git,运行后由用户派生)
+│ └── .env.example → 用户复制为 .env
+│
+├── 真源(运行生成,不进 Git/包)
+│ ├── config/runtime_state.yaml
+│ └── memory/pending_updates/*.json
+│
+├── 视图(由真源同步,可改但会被覆盖)
+│ ├── memory/internal_state.md
+│ ├── memory/interaction_settings.md
+│ └── chat/wechat_state.md
+│
+├── 用户数据(不进 Git,默认不进包)
+│ ├── .env
+│ ├── config/runtime_state.yaml
+│ ├── memory/*.md(不含 pending_updates/)
+│ ├── chat/*.md(不含 archive/)
+│ └── assets/*(头像/背景/横幅)
+│
+├── 缓存/临时(可安全删除)
+│ ├── logs/
+│ ├── backups/
+│ ├── memory/pending_updates/
+│ └── *.bak, *.tmp
+│
+└── 打包产物(进 .gitignore)
+ └── release/
+```
+
+---
+
+## 2. 逐文件分类
+
+### 2.1 源码(Source Code)
+
+| 文件 | 说明 | 进 Git | 进包 |
+|------|------|--------|------|
+| `app.py` | Streamlit 入口 | 是 | 是 |
+| `src/*.py` | 全部 Python 模块 | 是 | 是 |
+| `tests/*.py` | 测试套件 | 是 | 可选 |
+| `config/routing_rules.yaml` | 路由规则配置 | 是 | 是 |
+| `.env.example` | 环境变量模板 | 是 | 是 |
+| `requirements*.txt` | 依赖声明 | 是 | 是 |
+| `roles/*.md`, `templates/*.md` | 角色人设和 Prompt 模板 | 是 | 是 |
+| `tools/*.ps1`, `tools/*.py` | 打包/辅助工具 | 是 | 是 |
+| `docs/*.md` | 设计文档 | 是 | 是 |
+| `README.md`, `CHANGELOG.md`, `USER_GUIDE.md` | 项目文档 | 是 | 是 |
+
+源码的特征:**提交后不改,不依赖运行时状态**。
+
+### 2.2 配置模板 vs 用户配置
+
+| 文件 | 真源 | 进 Git | 进包 | 可删除 |
+|------|------|--------|------|--------|
+| `.env.example` | 手动维护 | **是** | 是 | 否 |
+| `.env` | 用户复制自 `.env.example` | **否** (.gitignore) | 否 (显式排除) | 是(需重新配) |
+
+`.env` 是唯一承载 API Key、Base URL 等敏感信息的文件。
+换电脑迁移时必须复制。
+
+### 2.3 运行时状态(真源)
+
+| 文件 | 真源角色 | 写入方 | 进 Git | 进包 |
+|------|---------|--------|--------|------|
+| `config/runtime_state.yaml` | **维一的运行时真源** | `mode_manager._write_runtime_state()` | **不应进** | 不推荐 |
+| `memory/pending_updates/*.json` | 群聊记忆候选真源 | `wechat_memory.save_candidates()` | **否** (.gitignore) | 否 |
+
+#### 真源层级说明
+
+`config/runtime_state.yaml` 包含版本、运行模式、交互设置、微信群状态。
+它的写入路径是:
+
+```
+mode_manager.update_*()
+ → _apply_state_updates()
+ → _write_runtime_state() # 先写 YAML(真源)
+ → _sync_runtime_state_markdown_views() # 再同步到以下视图
+```
+
+**为什么 YAML 是真源?** 因为 YAML 结构化、可程序化读写;MD 视图仅供人工阅读和 Streamlit 展示。
+**为什么不进 Git?** 因为 performance_mode、memory_mode、wechat_mode 等字段随每次使用变化,提交会产生噪音。
+
+> **当前问题**:`.gitignore` 未排除 `config/runtime_state.yaml`,有意外提交风险。建议添加。
+
+### 2.4 视图(View Only,由真源同步)
+
+| 文件 | 真源来源 | 被谁读取 | 可手动编辑 |
+|------|---------|---------|-----------|
+| `memory/internal_state.md` | `config/runtime_state.yaml` → synced | `mode_manager._read_md_state_migration()` | 可改,但会被 YAML 覆盖 |
+| `memory/interaction_settings.md` | 同上 | `mode_manager._read_md_state_migration()` | 同上 |
+| `chat/wechat_state.md` | 同上 | `wechat_state.py` | 同上 |
+| `memory/pending_updates/wechat_memory_candidates.md` | `.../*.json` | 人工预览 | 改也没用,JSON 是真源 |
+
+这些视图的存在意义:**让人眼可读、让 Streamlit cache 可缓存**。修改视图不会影响真源,下次同步会覆盖。
+
+### 2.5 用户数据(User Data)
+
+| 文件 | 内容 | 进 Git | 进包 | 可恢复 | 迁移要求 |
+|------|------|--------|------|--------|---------|
+| `memory/index.md` | 记忆索引 | 示例可进 | 默认排除 | 有备份 | 需迁移 |
+| `memory/current_focus.md` | 当前学习焦点 | 示例可进 | 默认排除 | 有备份 | 需迁移 |
+| `memory/summary.md` | 学习摘要 | 示例可进 | 默认排除 | 有备份 | 需迁移 |
+| `memory/learner_profile.md` | 学习者画像 | 示例可进 | 默认排除 | 有备份 | 需迁移 |
+| `memory/progress.md` | 学习进度 | 示例可进 | 默认排除 | 有备份 | 需迁移 |
+| `memory/project_context.md` | 项目上下文 | 示例可进 | 默认排除 | 有备份 | 需迁移 |
+| `memory/task_board.md` | 任务看板 | 示例可进 | 默认排除 | 有备份 | 需迁移 |
+| `memory/agent.md` | Agent 配置 | 示例可进 | 默认排除 | 有备份 | 需迁移 |
+| `memory/system_detail.md` | 系统详情 | 示例可进 | 默认排除 | 有备份 | 需迁移 |
+| `memory/archive_summary.md` | 归档摘要 | 示例可进 | 默认排除 | 有备份 | 需迁移 |
+| `chat/wechat_group.md` | 群聊记录 | **否** (.gitignore) | 否 | 可恢复(有 archive) | 可选 |
+| `chat/wechat_unread.md` | 未读消息 | **否** (.gitignore) | 否 | 丢 | 不迁 |
+| `chat/archive/*.md` | 历史群聊归档 | **否** (.gitignore) | 否 (显式排除) | 是 | 可选 |
+| `assets/*` | 头像/背景/横幅 | 建议不进(版权) | 否 (无扩展名排除) | 可重下 | 可选 |
+
+**关于 memory 进 Git 的约定**:`memory/*.md` 未被 `.gitignore` 排除。项目的预期行为是:
+- 示例/初始内容可提交一次进 Git
+- 真实学习数据**不进 Git**(用户自行维护 `.gitignore` 或在 `git add` 时留意)
+- 当前 `.gitignore` 只排除了 `memory/pending_updates/`
+
+#### 写入链路
+
+```
+app.py (用户操作)
+ → after_session / wechat_memory
+ → memory_writer.append_memory() / .write_current_focus()
+ → is_memory_write_allowed() 权限检查
+ → safe_writer.safe_write_text() # 先备份旧文件到 backups/,再原子写入
+```
+
+#### 恢复方式
+
+1. **从 backups 恢复** — 每次 `safe_write_text` 写入前自动备份旧文件到 `backups/memory_backups/`
+ ```bash
+ python -m src.backup_manager # 查看备份列表
+ python -m src.backup_manager restore # 恢复
+ ```
+2. **从 chat/archive 重建** — 群聊历史在 `chat/archive/*.md`,可通过 LLM 重新提取记忆
+
+### 2.6 会话/日志(可丢)
+
+| 文件 | 内容 | 进 Git | 进包 | 可删除 |
+|------|------|--------|------|--------|
+| `logs/current/*.md` | 当前会话日志(实时写入) | **否** (.gitignore) | 否 | 安全删除 |
+| `logs/sessions/*.md` | 已归档会话日志 | **否** (.gitignore) | 否 | 安全删除 |
+| `logs/revision_notes.md` | 修订笔记 | **否** (.gitignore) | 否 | 安全删除 |
+| `logs/session_archive.md` | 会话归档 | **否** (.gitignore) | 否 | 安全删除 |
+| `st.session_state` (内存) | Streamlit 会话状态 | — | — | 页面刷新即丢 |
+
+会话日志的写入链路:
+
+```
+app.py (对话)
+ → session_logger.log() # 追加到内存 _state
+ → session_logger.flush_current_session() # 按间隔刷到 logs/current/
+ → session_logger.save() # 归档到 logs/sessions/ + 清理 current/
+```
+
+**换电脑可以不迁 logs**。它们不是系统运行的必要状态。
+
+### 2.7 备份(Cache)
+
+| 文件 | 内容 | 进 Git | 进包 | 可删除 |
+|------|------|--------|------|--------|
+| `backups/memory_backups/*.bak` | 写入前自动备份 | **否** (.gitignore) | 否 | 安全删除 |
+| `*.bak`, `*.tmp` | 残留临时文件 | **否** (.gitignore) | 否 | 安全删除 |
+| `__pycache__/`, `.ruff_cache/` | Python/pyc 缓存 | **否** | 否 | 安全删除 |
+
+备份是安全网,不是真源。删除不会丢数据,但会失去最近一次写入前的回滚点。
+
+### 2.8 打包产物
+
+| 路径 | 进 Git | 进包 |
+|------|--------|------|
+| `release/` | **否** (.gitignore) | — |
+
+---
+
+## 3. 真源层级汇总
+
+```
+ 用户操作
+ │
+ ▼
+ st.session_state
+ (内存,页面刷新即丢)
+ │
+ ┌────────────┴────────────┐
+ │ │
+ config/ memory/
+ runtime_state.yaml pending_updates/*.json
+ ┌────┼────┐ │
+ │ │ │ ▼
+ ▼ ▼ ▼ pending_updates/*.md
+ memory/ memory/ chat/ (视图)
+ internal interaction wechat_
+ _state.md _settings.md state.md
+ (视图) (视图) (视图)
+
+ memory/*.md ←─── after_session.py / memory_writer ←─── 权限检查
+ chat/*.md ←─── wechat_state.py / wechat_generator.py
+ logs/* ←─── session_logger.py
+
+ backups/* ←─── safe_writer.py(自动备份旧文件)
+```
+
+### 读取优先级
+
+各模块读取状态的顺序(以 `mode_manager` 为例):
+
+1. `st.cache_data(ttl=30)` → `load_runtime_modes()` 缓存结果
+2. `_runtime_state_from_yaml()` → 读 `config/runtime_state.yaml`(真源)
+3. 如果 YAML 不存在 → 回退 `_read_md_state_migration()` 从 `memory/internal_state.md` + `memory/interaction_settings.md` + `chat/wechat_state.md` 读取(迁移兼容)
+4. 取默认值 `RuntimeModes()` 兜底
+
+---
+
+## 4. 运维操作指南
+
+### 4.1 换电脑迁移
+
+必须迁移的状态(丢则系统不完整):
+
+| 必须迁移 | 路径 | 原因 |
+|---------|------|------|
+| API 密钥 | `.env` | 无法连接 LLM |
+| 长期记忆 | `memory/*.md`(不含 `pending_updates/`) | 学习历史丢失 |
+| 运行时状态 | `config/runtime_state.yaml` | 可选,丢了可从默认重建 |
+
+建议迁移:
+
+| 建议迁移 | 路径 | 原因 |
+|---------|------|------|
+| 群聊历史 | `chat/`(含 `archive/`) | 学习讨论记录 |
+| 视觉资源 | `assets/` | 头像/背景 |
+| 角色印象 | `roles/*.md`(`### 对用户当前印象` 段) | 角色观察记录 |
+
+不需要迁移:
+
+| 不迁 | 原因 |
+|------|------|
+| `logs/` | 运行日志,对系统运行无用 |
+| `backups/` | 安全网,到新机器重新生成 |
+| `__pycache__/` | Python 会重建 |
+| `release/` | 打包产物 |
+
+### 4.2 安全删除(空间清理)
+
+```bash
+# 清空会话日志(最占空间)
+rm -rf logs/current/*.md logs/sessions/*.md
+
+# 清空备份
+rm -rf backups/memory_backups/*.bak
+
+# 清空临时文件
+find . -name "*.bak" -delete
+find . -name "*.tmp" -delete
+
+# 清空未读消息(重启后重建)
+echo "" > chat/wechat_unread.md
+```
+
+### 4.3 打包排除清单
+
+`tools/package_project_helper.py` 的 `EXCLUDE_DIRS`:
+
+```
+__pycache__, .pytest_cache, .ruff_cache, .git, .github,
+.vscode, .idea, venv, .venv, env, node_modules,
+logs, backups, exports, release, dist, build, 图片资料
+```
+
+额外排除规则:
+- `chat/archive/` — 历史群聊归档
+- `*.pyc, *.pyo, *.bak, *.tmp` — 临时文件
+- `.env, .env.*`(除非 `.env.example`)— 用户密钥
+- `tools/package_project_v*.ps1` — 旧版打包脚本
+- 含 `visual_assets_pack` 的路径
+
+打包前还会扫描密钥模式(sk-*, ghp_*, sk-or-v1-*, api_key/token/secret 赋值)。
+
+---
+
+## 5. 当前已知问题
+
+1. **`config/runtime_state.yaml` 未进 `.gitignore`** — 虽无敏感信息,但频繁变动不应提交。
+2. **memory/*.md 示例/真实数据混用** — `.gitignore` 应增加 `memory/` 排除,或只留示例文件。
+3. **chat/ 被 .gitignore 排除但 runtime_state.yaml 未排除** — 不一致。
+4. **`memory/pending_updates/` 缓存的 JSON 与 MD 之间没有版本对应** — JSON 是真源,MD 是视图,但二者无显式关联约束。
+5. **`memory/pending_updates/` 不自动清理** — 确认写入后,候选文件应删除或标记已处理,当前无此逻辑。
+
+---
+
+## 6. 推荐改进
+
+```gitignore
+# 在 .gitignore 中补充:
+config/runtime_state.yaml
+memory/
+!memory/.gitkeep
+```
+
+同时为 `memory/` 添加一个 `.gitkeep` 保持目录结构,初始 memory 文件可作为示例通过 `git add --force` 选择性提交。
diff --git a/memory/internal_state.md b/memory/internal_state.md
index 987d7e5..4c8b4fc 100644
--- a/memory/internal_state.md
+++ b/memory/internal_state.md
@@ -9,6 +9,6 @@
- entry_mode: wechat
## Version
-- current_version: v0.7.7
-- active_task: post-v0.7.7 stabilization and UI polish
-- next_version: v0.7.8
+- current_version: v0.8.0
+- active_task: 文档同步 + UI 中文标签 + 性能预算 + 状态模型 + 工程收口
+- next_version: v0.8.1
diff --git a/requirements-dev.in b/requirements-dev.in
new file mode 100644
index 0000000..bedbee9
--- /dev/null
+++ b/requirements-dev.in
@@ -0,0 +1,6 @@
+-r requirements.in
+
+pytest>=8.0,<9
+ruff>=0.6,<1
+mypy>=1.0,<2
+detect-secrets>=1.5,<2
diff --git a/requirements-dev.txt b/requirements-dev.txt
index c74acab..acfc17f 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,4 +1,83 @@
-pytest>=8.0,<9
-ruff>=0.6,<1
-mypy>=1.0,<2
-detect-secrets>=1.5,<2
+altair==6.1.0
+annotated-types==0.7.0
+anyio==4.13.0
+attrs==26.1.0
+babel==2.18.0
+blinker==1.9.0
+cachetools==7.1.2
+certifi==2026.4.22
+chardet==7.4.3
+charset-normalizer==3.4.7
+click==8.4.0
+colorama==0.4.6
+courlan==1.3.2
+cssselect==1.4.0
+dateparser==1.4.0
+detect-secrets==1.5.0
+distro==1.9.0
+gitdb==4.0.12
+gitpython==3.1.50
+h11==0.16.0
+htmldate==1.9.4
+httpcore==1.0.9
+httptools==0.7.1
+httpx==0.28.1
+idna==3.15
+iniconfig==2.3.0
+itsdangerous==2.2.0
+jinja2==3.1.6
+jiter==0.14.0
+jsonschema==4.26.0
+jsonschema-specifications==2025.9.1
+justext==3.0.2
+librt==0.11.0
+lxml[html-clean]==6.1.0
+lxml-html-clean==0.4.4
+markupsafe==3.0.3
+mypy==1.20.2
+mypy-extensions==1.1.0
+narwhals==2.21.2
+numpy==2.4.5
+openai==1.109.1
+packaging==26.2
+pandas==3.0.3
+pathspec==1.1.1
+pillow==12.2.0
+pluggy==1.6.0
+protobuf==7.34.1
+pyarrow==24.0.0
+pydantic==2.13.4
+pydantic-core==2.46.4
+pydeck==0.9.2
+pygments==2.20.0
+pytest==8.4.2
+python-dateutil==2.9.0.post0
+python-docx==1.2.0
+python-dotenv==1.2.2
+python-multipart==0.0.28
+pytz==2026.2
+pyyaml==6.0.3
+readability-lxml==0.8.4.1
+referencing==0.37.0
+regex==2026.5.9
+requests==2.34.2
+rpds-py==0.30.0
+ruff==0.15.13
+six==1.17.0
+smmap==5.0.3
+sniffio==1.3.1
+starlette==1.0.0
+streamlit==1.57.0
+tenacity==9.1.4
+tld==0.13.2
+toml==0.10.2
+tqdm==4.67.3
+trafilatura==2.0.0
+typing-extensions==4.15.0
+typing-inspection==0.4.2
+tzdata==2026.2
+tzlocal==5.3.1
+urllib3==2.7.0
+uvicorn==0.47.0
+watchdog==6.0.0
+websockets==16.0
diff --git a/requirements.in b/requirements.in
new file mode 100644
index 0000000..3c098d9
--- /dev/null
+++ b/requirements.in
@@ -0,0 +1,8 @@
+streamlit>=1.35,<2
+openai>=1.0,<2
+python-dotenv>=1.0,<2
+pyyaml>=6.0,<7
+python-docx>=1.1,<2
+trafilatura>=1.12,<3
+readability-lxml>=0.8,<1
+lxml>=5.0,<7
diff --git a/requirements.txt b/requirements.txt
index 3c098d9..91378ab 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,8 +1,73 @@
-streamlit>=1.35,<2
-openai>=1.0,<2
-python-dotenv>=1.0,<2
-pyyaml>=6.0,<7
-python-docx>=1.1,<2
-trafilatura>=1.12,<3
-readability-lxml>=0.8,<1
-lxml>=5.0,<7
+altair==6.1.0
+annotated-types==0.7.0
+anyio==4.13.0
+attrs==26.1.0
+babel==2.18.0
+blinker==1.9.0
+cachetools==7.1.2
+certifi==2026.4.22
+chardet==7.4.3
+charset-normalizer==3.4.7
+click==8.4.0
+colorama==0.4.6
+courlan==1.3.2
+cssselect==1.4.0
+dateparser==1.4.0
+distro==1.9.0
+gitdb==4.0.12
+gitpython==3.1.50
+h11==0.16.0
+htmldate==1.9.4
+httpcore==1.0.9
+httptools==0.7.1
+httpx==0.28.1
+idna==3.15
+itsdangerous==2.2.0
+jinja2==3.1.6
+jiter==0.14.0
+jsonschema==4.26.0
+jsonschema-specifications==2025.9.1
+justext==3.0.2
+lxml[html-clean]==6.1.0
+lxml-html-clean==0.4.4
+markupsafe==3.0.3
+narwhals==2.21.2
+numpy==2.4.5
+openai==1.109.1
+packaging==26.2
+pandas==3.0.3
+pillow==12.2.0
+protobuf==7.34.1
+pyarrow==24.0.0
+pydantic==2.13.4
+pydantic-core==2.46.4
+pydeck==0.9.2
+python-dateutil==2.9.0.post0
+python-docx==1.2.0
+python-dotenv==1.2.2
+python-multipart==0.0.28
+pytz==2026.2
+pyyaml==6.0.3
+readability-lxml==0.8.4.1
+referencing==0.37.0
+regex==2026.5.9
+requests==2.34.2
+rpds-py==0.30.0
+six==1.17.0
+smmap==5.0.3
+sniffio==1.3.1
+starlette==1.0.0
+streamlit==1.57.0
+tenacity==9.1.4
+tld==0.13.2
+toml==0.10.2
+tqdm==4.67.3
+trafilatura==2.0.0
+typing-extensions==4.15.0
+typing-inspection==0.4.2
+tzdata==2026.2
+tzlocal==5.3.1
+urllib3==2.7.0
+uvicorn==0.47.0
+watchdog==6.0.0
+websockets==16.0
diff --git a/src/constants.py b/src/constants.py
index 50fb61e..8a066be 100644
--- a/src/constants.py
+++ b/src/constants.py
@@ -36,11 +36,11 @@
"概念地图": "🗺",
}
-MODEL_LABELS = {"auto": "Auto", "flash": "Flash", "pro": "Pro"}
+MODEL_LABELS = {"auto": "自动", "flash": "Flash(快速)", "pro": "Pro(高质量)"}
MODEL_ICONS = {"auto": "🧩", "flash": "⚡", "pro": "💎"}
-PERF_LABELS = {"fast": "Fast", "standard": "Standard", "deep": "Deep"}
+PERF_LABELS = {"fast": "快速", "standard": "标准", "deep": "深度"}
PERF_ICONS = {"fast": "🚀", "standard": "🎯", "deep": "🔍"}
diff --git a/src/mode_manager.py b/src/mode_manager.py
index 8eca51e..0ae6f10 100644
--- a/src/mode_manager.py
+++ b/src/mode_manager.py
@@ -26,9 +26,9 @@ class RuntimeModes:
route_mode: str = "auto_rule"
debug_mode: bool = False
safe_mode: bool = False
- current_version: str = "v0.7.7"
- active_task: str = "post-v0.7.7 stabilization and UI polish"
- next_version: str = "v0.7.8"
+ current_version: str = "v0.8.0"
+ active_task: str = "文档同步 + UI 中文标签 + 性能预算 + 状态模型 + 工程收口"
+ next_version: str = "v0.8.1"
relationship_mode: str = "standard"
wechat_mode: str = "unread_feedback"
user_has_joined: bool = False
diff --git a/src/news/digest.py b/src/news/digest.py
index f0e3979..db6120a 100644
--- a/src/news/digest.py
+++ b/src/news/digest.py
@@ -6,6 +6,7 @@
from src.llm_client import ModelProfile, chat
from src.news.link_resolver import _display_link_host
+from src.performance_budget import news_digest_max_tokens
from src.news.rss_fetcher import normalize_news_query
@@ -173,4 +174,10 @@ def generate_news_digest(
),
},
]
- return chat(messages, temperature=0.3, model_profile=model_profile).strip()
+ return chat(
+ messages,
+ temperature=0.3,
+ model_profile=model_profile,
+ max_tokens=news_digest_max_tokens(performance_mode),
+ task_name="news_digest",
+ ).strip()
diff --git a/src/performance_budget.py b/src/performance_budget.py
new file mode 100644
index 0000000..9a348bf
--- /dev/null
+++ b/src/performance_budget.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+
+def normalize_performance_mode(mode: str | None) -> str:
+ if mode in {"fast", "standard", "deep"}:
+ return mode
+ return "standard"
+
+
+def chat_max_tokens(mode: str | None) -> int:
+ mode = normalize_performance_mode(mode)
+ return {
+ "fast": 700,
+ "standard": 1100,
+ "deep": 1600,
+ }[mode]
+
+
+def wechat_history_lines(mode: str | None) -> int:
+ mode = normalize_performance_mode(mode)
+ return {
+ "fast": 16,
+ "standard": 28,
+ "deep": 40,
+ }[mode]
+
+
+def wechat_reply_max_tokens(mode: str | None) -> int:
+ mode = normalize_performance_mode(mode)
+ return {
+ "fast": 520,
+ "standard": 760,
+ "deep": 1050,
+ }[mode]
+
+
+def wechat_opening_max_tokens(mode: str | None) -> int:
+ mode = normalize_performance_mode(mode)
+ return {
+ "fast": 420,
+ "standard": 620,
+ "deep": 850,
+ }[mode]
+
+
+def news_digest_max_tokens(mode: str | None) -> int:
+ mode = normalize_performance_mode(mode)
+ return {
+ "fast": 650,
+ "standard": 950,
+ "deep": 1300,
+ }[mode]
+
+
+def news_discussion_max_tokens(mode: str | None) -> int:
+ mode = normalize_performance_mode(mode)
+ return {
+ "fast": 520,
+ "standard": 760,
+ "deep": 1000,
+ }[mode]
diff --git a/src/ui/chat_panel.py b/src/ui/chat_panel.py
index 13cbbf9..4384560 100644
--- a/src/ui/chat_panel.py
+++ b/src/ui/chat_panel.py
@@ -8,6 +8,7 @@
from src.context_builder import build_messages
from src.llm_client import stream_chat
from src.memory import read_memory_bundle
+from src.performance_budget import chat_max_tokens
from src.mode_manager import load_runtime_modes
from src.model_stats import estimate_tokens, record_call, record_perf
from src.perf import PerfTracker, write_perf_log
@@ -38,9 +39,9 @@ def _render_history_message(msg: dict):
_DISPLAY_LABELS = {
"mode": {"auto": "自动"},
- "model": {"auto": "Auto", "flash": "Flash", "pro": "Pro"},
- "perf": {"fast": "Fast", "standard": "Standard", "deep": "Deep"},
- "atmos": {"standard": "Standard", "warm": "Warm", "close": "Close"},
+ "model": {"auto": "自动", "flash": "Flash(快速)", "pro": "Pro(高质量)"},
+ "perf": {"fast": "快速", "standard": "标准", "deep": "深度"},
+ "atmos": {"standard": "标准", "warm": "温和", "close": "贴近"},
}
@@ -280,6 +281,8 @@ def _mark_first_token():
api_messages,
model_profile=resolved_model,
on_first_token=_mark_first_token,
+ max_tokens=chat_max_tokens(runtime_modes.performance_mode),
+ task_name="single_chat",
)
reply = st.write_stream(stream)
perf.set("llm_total_time", time.perf_counter() - llm_started)
diff --git a/src/ui/status_bar.py b/src/ui/status_bar.py
index 4e12b10..11c37af 100644
--- a/src/ui/status_bar.py
+++ b/src/ui/status_bar.py
@@ -211,22 +211,22 @@ def render_model_stats_line():
return
st.caption(
- f"Flash: {stats.flash_calls} | Pro: {stats.pro_calls} | "
- f"Router: {stats.llm_router_calls} | "
- f"Last latency: {stats.last_latency:.2f}s | Cost: ¥{estimated_cost():.4f}"
+ f"Flash 调用: {stats.flash_calls} | Pro 调用: {stats.pro_calls} | "
+ f"路由调用: {stats.llm_router_calls} | "
+ f"上次延迟: {stats.last_latency:.2f}s | 估算成本: ¥{estimated_cost():.4f}"
)
perf = stats.last_perf or st.session_state.get("perf_metrics", {})
if st.session_state.runtime_modes.debug_mode and perf:
st.caption(
- "Perf: "
- f"route {perf.get('route_time', 0):.3f}s | "
- f"memory {perf.get('memory_read_time', 0):.3f}s | "
- f"context {perf.get('context_build_time', 0):.3f}s | "
- f"first token {perf.get('llm_first_token_time', 0):.3f}s | "
- f"llm {perf.get('llm_total_time', 0):.3f}s | "
- f"ui {perf.get('ui_render_time', 0):.3f}s | "
- f"total {perf.get('total_time', 0):.3f}s"
+ "性能: "
+ f"路由 {perf.get('route_time', 0):.3f}s | "
+ f"记忆 {perf.get('memory_read_time', 0):.3f}s | "
+ f"上下文 {perf.get('context_build_time', 0):.3f}s | "
+ f"首 token {perf.get('llm_first_token_time', 0):.3f}s | "
+ f"模型 {perf.get('llm_total_time', 0):.3f}s | "
+ f"UI {perf.get('ui_render_time', 0):.3f}s | "
+ f"总计 {perf.get('total_time', 0):.3f}s"
)
if st.button("重置统计"):
diff --git a/src/ui/wechat_panel.py b/src/ui/wechat_panel.py
index 9e3ebc5..05e2c40 100644
--- a/src/ui/wechat_panel.py
+++ b/src/ui/wechat_panel.py
@@ -63,12 +63,27 @@ def _apply_mark_wechat_read(session_id: str, session_state) -> None:
session_state.wechat_messages = read_wechat_group()
+def _clear_wechat_news_state(session_state) -> None:
+ session_state.wechat_news_items = []
+ session_state.wechat_news_digest = ""
+ session_state.wechat_news_phase = ""
+ session_state.wechat_news_query_text = ""
+ session_state.wechat_news_read_articles = True
+ session_state.wechat_news_source_block = ""
+ session_state.wechat_news_coverage = {}
+ session_state.wechat_news_warnings = []
+ session_state.wechat_news_elapsed_ms = 0
+
+
+def _should_render_news_phase_before_group(session_state) -> bool:
+ return bool(session_state.get("wechat_news_phase"))
+
+
def _apply_new_wechat_group(session_state) -> None:
reset_wechat_group()
session_state.wechat_messages = None
session_state.wechat_pending_input = None
- session_state.wechat_news_items = []
- session_state.wechat_news_digest = ""
+ _clear_wechat_news_state(session_state)
def _rerun_wechat_fragment():
@@ -433,6 +448,11 @@ def render_wechat_panel():
content_placeholder = st.empty()
if not group_started:
+ if _should_render_news_phase_before_group(st.session_state):
+ st.caption("已获取新闻搜索结果,请按阶段继续生成摘要和群聊讨论。")
+ render_news_round_phases()
+ return
+
st.caption("也可以直接联网查一个话题,系统会自动拉起一轮群聊讨论。")
_render_opening_setup()
return
diff --git a/src/wechat_generator.py b/src/wechat_generator.py
index d6b0b32..0175379 100644
--- a/src/wechat_generator.py
+++ b/src/wechat_generator.py
@@ -7,6 +7,12 @@
from src.llm_client import ModelProfile, chat, stream_chat
from src.mode_manager import load_runtime_modes
+from src.performance_budget import (
+ news_discussion_max_tokens,
+ wechat_history_lines,
+ wechat_opening_max_tokens,
+ wechat_reply_max_tokens,
+)
from src.role_manager import load_role
from src.wechat_format import (
PERFORMANCE_STYLE_HINTS,
@@ -68,7 +74,13 @@ def generate_wechat_news_discussion(
),
},
]
- raw = chat(messages, temperature=0.7, model_profile=model_profile).strip()
+ raw = chat(
+ messages,
+ temperature=0.7,
+ model_profile=model_profile,
+ max_tokens=news_discussion_max_tokens(performance_mode),
+ task_name="wechat_news_discussion",
+ ).strip()
return _ensure_all_roles_reply(raw)
@@ -86,7 +98,8 @@ def _build_interactive_messages(
is_first = not modes.first_reaction_done
prompt = load_interactive_prompt()
history = read_wechat_group()
- history_lines = history.splitlines()[-40:] if history else []
+ history_limit = wechat_history_lines(modes.performance_mode)
+ history_lines = history.splitlines()[-history_limit:] if history else []
if is_first:
prompt += (
@@ -230,6 +243,8 @@ def generate_wechat_opening(
],
temperature=0.8,
model_profile=model_profile,
+ max_tokens=wechat_opening_max_tokens(performance_mode),
+ task_name="wechat_opening",
).strip()
return _ensure_all_roles_reply(opening)
@@ -243,7 +258,13 @@ def generate_interactive_wechat_reply(
relationship_mode: str | None = None,
) -> str:
messages, _is_first = _build_interactive_messages(user_text, relationship_mode)
- raw = chat(messages, temperature=0.7, model_profile=model_profile)
+ raw = chat(
+ messages,
+ temperature=0.7,
+ model_profile=model_profile,
+ max_tokens=wechat_reply_max_tokens(load_runtime_modes().performance_mode),
+ task_name="wechat_interactive",
+ )
return _ensure_all_roles_reply(raw.strip())
@@ -253,7 +274,16 @@ def generate_interactive_wechat_reply_stream(
relationship_mode: str | None = None,
):
messages, is_first = _build_interactive_messages(user_text, relationship_mode)
- return stream_chat(messages, temperature=0.7, model_profile=model_profile), is_first
+ return (
+ stream_chat(
+ messages,
+ temperature=0.7,
+ model_profile=model_profile,
+ max_tokens=wechat_reply_max_tokens(load_runtime_modes().performance_mode),
+ task_name="wechat_interactive",
+ ),
+ is_first,
+ )
def normalize_interactive_wechat_reply(content: str) -> str:
diff --git a/src/wechat_service.py b/src/wechat_service.py
index ff1c9b6..e2934b9 100644
--- a/src/wechat_service.py
+++ b/src/wechat_service.py
@@ -5,6 +5,7 @@
from typing import Callable
from src.session_logger import set_wechat_interactive
+from src.mode_manager import update_wechat_join_state
from src.news.article_fetcher import enrich_news_items_with_article_text
from src.news.digest import format_news_source_block, generate_news_digest
from src.news.rss_fetcher import fetch_news_items
@@ -178,7 +179,15 @@ def run_discussion_stage(
if source_block:
append_system_group_note(source_block)
+
append_interactive_group_reply(discussion)
+
+ update_wechat_join_state(
+ user_has_joined=False,
+ first_reaction_done=False,
+ mode="interactive_group",
+ )
+
group_content = read_wechat_group()
if session_id:
diff --git a/tests/test_packaging_guards.py b/tests/test_packaging_guards.py
index ea44390..34b8784 100644
--- a/tests/test_packaging_guards.py
+++ b/tests/test_packaging_guards.py
@@ -156,12 +156,12 @@ def test_runtime_version_is_synced():
state_text = Path("memory/internal_state.md").read_text(encoding="utf-8")
yaml_text = Path("config/runtime_state.yaml").read_text(encoding="utf-8")
- assert 'current_version: str = "v0.7.7"' in mode_text
- assert 'next_version: str = "v0.7.8"' in mode_text
- assert '- current_version: v0.7.7' in state_text
- assert '- next_version: v0.7.8' in state_text
- assert "current: v0.7.7" in yaml_text
- assert "next: v0.7.8" in yaml_text
+ assert 'current_version: str = "v0.8.0"' in mode_text
+ assert 'next_version: str = "v0.8.1"' in mode_text
+ assert '- current_version: v0.8.0' in state_text
+ assert '- next_version: v0.8.1' in state_text
+ assert "current: v0.8.0" in yaml_text
+ assert "next: v0.8.1" in yaml_text
def test_ci_workflow_exists_and_runs_core_checks():
diff --git a/tests/test_performance_budget.py b/tests/test_performance_budget.py
new file mode 100644
index 0000000..5b501a3
--- /dev/null
+++ b/tests/test_performance_budget.py
@@ -0,0 +1,99 @@
+"""Test performance budget module — verify ordering and fallback."""
+from __future__ import annotations
+
+from src.performance_budget import (
+ chat_max_tokens,
+ news_digest_max_tokens,
+ news_discussion_max_tokens,
+ wechat_history_lines,
+ wechat_opening_max_tokens,
+ wechat_reply_max_tokens,
+)
+
+
+def _assert_strictly_increasing(values: list[int]):
+ for a, b in zip(values, values[1:]):
+ assert a < b, f"Expected {a} < {b}"
+
+
+class TestChatMaxTokens:
+ def test_fast_lt_standard_lt_deep(self):
+ _assert_strictly_increasing([
+ chat_max_tokens("fast"),
+ chat_max_tokens("standard"),
+ chat_max_tokens("deep"),
+ ])
+
+ def test_none_falls_back_to_standard(self):
+ assert chat_max_tokens(None) == chat_max_tokens("standard")
+
+ def test_unknown_mode_falls_back_to_standard(self):
+ assert chat_max_tokens("unknown") == chat_max_tokens("standard")
+
+ def test_values_are_positive(self):
+ for mode in ("fast", "standard", "deep"):
+ assert chat_max_tokens(mode) > 0
+
+
+class TestWechatHistoryLines:
+ def test_fast_lt_standard_lt_deep(self):
+ _assert_strictly_increasing([
+ wechat_history_lines("fast"),
+ wechat_history_lines("standard"),
+ wechat_history_lines("deep"),
+ ])
+
+ def test_unknown_falls_back(self):
+ assert wechat_history_lines(None) == wechat_history_lines("standard")
+
+ def test_values_are_positive(self):
+ for mode in ("fast", "standard", "deep"):
+ assert wechat_history_lines(mode) > 0
+
+
+class TestWechatReplyMaxTokens:
+ def test_fast_lt_standard_lt_deep(self):
+ _assert_strictly_increasing([
+ wechat_reply_max_tokens("fast"),
+ wechat_reply_max_tokens("standard"),
+ wechat_reply_max_tokens("deep"),
+ ])
+
+ def test_unknown_falls_back(self):
+ assert wechat_reply_max_tokens(None) == wechat_reply_max_tokens("standard")
+
+
+class TestWechatOpeningMaxTokens:
+ def test_fast_lt_standard_lt_deep(self):
+ _assert_strictly_increasing([
+ wechat_opening_max_tokens("fast"),
+ wechat_opening_max_tokens("standard"),
+ wechat_opening_max_tokens("deep"),
+ ])
+
+ def test_unknown_falls_back(self):
+ assert wechat_opening_max_tokens(None) == wechat_opening_max_tokens("standard")
+
+
+class TestNewsDigestMaxTokens:
+ def test_fast_lt_standard_lt_deep(self):
+ _assert_strictly_increasing([
+ news_digest_max_tokens("fast"),
+ news_digest_max_tokens("standard"),
+ news_digest_max_tokens("deep"),
+ ])
+
+ def test_unknown_falls_back(self):
+ assert news_digest_max_tokens(None) == news_digest_max_tokens("standard")
+
+
+class TestNewsDiscussionMaxTokens:
+ def test_fast_lt_standard_lt_deep(self):
+ _assert_strictly_increasing([
+ news_discussion_max_tokens("fast"),
+ news_discussion_max_tokens("standard"),
+ news_discussion_max_tokens("deep"),
+ ])
+
+ def test_unknown_falls_back(self):
+ assert news_discussion_max_tokens(None) == news_discussion_max_tokens("standard")
diff --git a/tests/test_wechat_news_entry_flow.py b/tests/test_wechat_news_entry_flow.py
new file mode 100644
index 0000000..f21f8b8
--- /dev/null
+++ b/tests/test_wechat_news_entry_flow.py
@@ -0,0 +1,119 @@
+"""Test the news entry state machine fix:
+
+- _should_render_news_phase_before_group detects wechat_news_phase
+- _clear_wechat_news_state resets all news-related session state
+"""
+from __future__ import annotations
+
+from src.ui.wechat_panel import (
+ _clear_wechat_news_state,
+ _should_render_news_phase_before_group,
+)
+
+
+class _FakeSessionState(dict):
+ """dict subclass that also supports attribute access like st.session_state."""
+ def __getattr__(self, name: str):
+ try:
+ return self[name]
+ except KeyError:
+ raise AttributeError(name) from None
+
+ def __setattr__(self, name: str, value):
+ self[name] = value
+
+ def __delattr__(self, name: str):
+ try:
+ del self[name]
+ except KeyError:
+ raise AttributeError(name) from None
+
+
+def _make_session(**overrides) -> _FakeSessionState:
+ """Build a fake session_state-like object with news defaults."""
+ ss = _FakeSessionState()
+ for k, v in {
+ "wechat_news_items": [],
+ "wechat_news_digest": "",
+ "wechat_news_phase": "",
+ "wechat_news_query_text": "",
+ "wechat_news_read_articles": True,
+ "wechat_news_source_block": "",
+ "wechat_news_coverage": {},
+ "wechat_news_warnings": [],
+ "wechat_news_elapsed_ms": 0,
+ }.items():
+ ss[k] = v
+ for k, v in overrides.items():
+ ss[k] = v
+ return ss
+
+
+# ── _should_render_news_phase_before_group ────────────────────────────────
+
+
+class TestShouldRenderNewsPhaseBeforeGroup:
+ def test_phase_searched_returns_true(self):
+ ss = _make_session(wechat_news_phase="searched")
+ assert _should_render_news_phase_before_group(ss) is True
+
+ def test_phase_empty_returns_false(self):
+ ss = _make_session(wechat_news_phase="")
+ assert _should_render_news_phase_before_group(ss) is False
+
+ def test_phase_none_returns_false(self):
+ ss = _make_session()
+ del ss.wechat_news_phase
+ assert _should_render_news_phase_before_group(ss) is False
+
+ def test_phase_other_string_returns_true(self):
+ ss = _make_session(wechat_news_phase="digested")
+ assert _should_render_news_phase_before_group(ss) is True
+
+ def test_phase_discussed_returns_true(self):
+ ss = _make_session(wechat_news_phase="discussed")
+ assert _should_render_news_phase_before_group(ss) is True
+
+
+# ── _clear_wechat_news_state ──────────────────────────────────────────────
+
+
+class TestClearWechatNewsState:
+ def test_clears_all_fields(self):
+ ss = _make_session(
+ wechat_news_items=[{"title": "A"}],
+ wechat_news_digest="some digest",
+ wechat_news_phase="searched",
+ wechat_news_query_text="latest AI news",
+ wechat_news_read_articles=False,
+ wechat_news_source_block="source block",
+ wechat_news_coverage={"total": 5},
+ wechat_news_warnings=["warning"],
+ wechat_news_elapsed_ms=1234,
+ )
+
+ _clear_wechat_news_state(ss)
+
+ assert ss.wechat_news_items == []
+ assert ss.wechat_news_digest == ""
+ assert ss.wechat_news_phase == ""
+ assert ss.wechat_news_query_text == ""
+ assert ss.wechat_news_source_block == ""
+ assert ss.wechat_news_coverage == {}
+ assert ss.wechat_news_warnings == []
+ assert ss.wechat_news_elapsed_ms == 0
+ assert ss.wechat_news_read_articles is True
+
+ def test_idempotent_on_already_clean(self):
+ ss = _make_session()
+ _clear_wechat_news_state(ss)
+
+ assert ss.wechat_news_items == []
+ assert ss.wechat_news_digest == ""
+ assert ss.wechat_news_phase == ""
+ assert ss.wechat_news_query_text == ""
+ assert ss.wechat_news_read_articles is True
+ assert ss.wechat_news_source_block == ""
+ assert ss.wechat_news_coverage == {}
+ assert ss.wechat_news_warnings == []
+ assert ss.wechat_news_elapsed_ms == 0
diff --git a/tests/test_wechat_service_news_flow.py b/tests/test_wechat_service_news_flow.py
new file mode 100644
index 0000000..808325f
--- /dev/null
+++ b/tests/test_wechat_service_news_flow.py
@@ -0,0 +1,215 @@
+"""Test the wechat_service news flow with state sync fix.
+
+- run_discussion_stage must call update_wechat_join_state with correct args
+- WeChat generator calls must pass max_tokens and task_name
+"""
+from __future__ import annotations
+
+
+# ── run_discussion_stage ──────────────────────────────────────────────────
+
+
+class TestRunDiscussionStage:
+ def test_writes_discussion_and_syncs_join_state(self, monkeypatch):
+ from src import wechat_service
+
+ writes: list[str] = []
+ join_state_calls: list[tuple] = []
+
+ monkeypatch.setattr(
+ wechat_service,
+ "generate_wechat_news_discussion",
+ lambda *args, **kwargs: "mock discussion text",
+ )
+ monkeypatch.setattr(
+ wechat_service,
+ "append_system_group_note",
+ lambda content: writes.append(("note", content)),
+ )
+ monkeypatch.setattr(
+ wechat_service,
+ "append_interactive_group_reply",
+ lambda content: writes.append(("reply", content)),
+ )
+ monkeypatch.setattr(
+ wechat_service,
+ "update_wechat_join_state",
+ lambda user_has_joined, first_reaction_done, mode: join_state_calls.append(
+ (user_has_joined, first_reaction_done, mode)
+ ),
+ )
+ monkeypatch.setattr(wechat_service, "read_wechat_group", lambda: "group content")
+
+ discussion, group_content = wechat_service.run_discussion_stage(
+ digest="test digest",
+ interaction_mode="warm",
+ performance_mode="standard",
+ selected_model="flash",
+ source_block="source block text",
+ )
+
+ # Verify output
+ assert discussion == "mock discussion text"
+ assert group_content == "group content"
+
+ # Verify writes in order
+ assert writes == [
+ ("note", "source block text"),
+ ("reply", "mock discussion text"),
+ ]
+ # Verify join state was synced with correct args
+ assert join_state_calls == [
+ (False, False, "interactive_group"),
+ ]
+
+ def test_writes_discussion_without_source_block(self, monkeypatch):
+ from src import wechat_service
+
+ writes: list[str] = []
+ join_state_calls: list[tuple] = []
+
+ monkeypatch.setattr(
+ wechat_service,
+ "generate_wechat_news_discussion",
+ lambda *args, **kwargs: "discussion",
+ )
+ monkeypatch.setattr(
+ wechat_service,
+ "append_system_group_note",
+ lambda content: writes.append(("note", content)),
+ )
+ monkeypatch.setattr(
+ wechat_service,
+ "append_interactive_group_reply",
+ lambda content: writes.append(("reply", content)),
+ )
+ monkeypatch.setattr(
+ wechat_service,
+ "update_wechat_join_state",
+ lambda user_has_joined, first_reaction_done, mode: join_state_calls.append(
+ (user_has_joined, first_reaction_done, mode)
+ ),
+ )
+ monkeypatch.setattr(wechat_service, "read_wechat_group", lambda: "group")
+
+ wechat_service.run_discussion_stage(
+ digest="digest",
+ interaction_mode="standard",
+ performance_mode="fast",
+ selected_model="auto",
+ source_block="",
+ )
+
+ # Without source_block, no system note should be written
+ assert writes == [("reply", "discussion")]
+ # Join state should still be synced
+ assert join_state_calls == [(False, False, "interactive_group")]
+
+
+# ── wechat_generator budget parameter verification ───────────────────────
+
+
+class TestWechatGeneratorBudget:
+ def test_generate_wechat_news_discussion_passes_max_tokens_and_task_name(
+ self, monkeypatch
+ ):
+ from src import wechat_generator
+
+ call_kwargs: dict = {}
+
+ def _mock_chat(*args, **kwargs):
+ call_kwargs.update(kwargs)
+ return "【三月七】\nok\n\n【刻晴】\nok\n\n【纳西妲】\nok\n\n【流萤】\nok"
+
+ monkeypatch.setattr(wechat_generator, "chat", _mock_chat)
+
+ result = wechat_generator.generate_wechat_news_discussion(
+ news_digest="test digest",
+ relationship_mode="standard",
+ performance_mode="fast",
+ selected_model="auto",
+ )
+
+ assert result
+ assert call_kwargs.get("max_tokens") is not None
+ assert call_kwargs.get("task_name") == "wechat_news_discussion"
+
+ def test_generate_interactive_wechat_reply_stream_passes_max_tokens_and_task_name(
+ self, monkeypatch
+ ):
+ from src import wechat_generator
+
+ call_kwargs: dict = {}
+ stream_result = iter(["hello"])
+
+ def _mock_stream_chat(*args, **kwargs):
+ call_kwargs.update(kwargs)
+ return stream_result
+
+ monkeypatch.setattr(wechat_generator, "stream_chat", _mock_stream_chat)
+ monkeypatch.setattr(wechat_generator, "read_wechat_group", lambda: "【三月七】\nhi")
+ monkeypatch.setattr(
+ wechat_generator, "load_runtime_modes", lambda: type("Modes", (), {
+ "performance_mode": "fast",
+ "relationship_mode": "standard",
+ "first_reaction_done": True,
+ })()
+ )
+
+ stream, _is_first = wechat_generator.generate_interactive_wechat_reply_stream(
+ user_text="hello",
+ model_profile="flash",
+ relationship_mode="standard",
+ )
+
+ # Exhaust the iterator so the generator runs
+ list(stream)
+
+ assert call_kwargs.get("max_tokens") is not None
+ assert call_kwargs.get("task_name") == "wechat_interactive"
+
+ def test_generate_news_digest_passes_max_tokens_and_task_name(self, monkeypatch):
+ from src.news import digest as news_digest_module
+
+ call_kwargs: dict = {}
+
+ def _mock_chat(*args, **kwargs):
+ call_kwargs.update(kwargs)
+ return "【搜索结果摘要】\nno news"
+
+ monkeypatch.setattr(news_digest_module, "chat", _mock_chat)
+
+ result = news_digest_module.generate_news_digest(
+ news_items=[{"title": "A", "source": "S", "published_at": "today"}],
+ performance_mode="standard",
+ selected_model="auto",
+ )
+
+ assert result
+ assert call_kwargs.get("max_tokens") is not None
+ assert call_kwargs.get("task_name") == "news_digest"
+
+ def test_generate_wechat_opening_passes_max_tokens_and_task_name(
+ self, monkeypatch
+ ):
+ from src import wechat_generator
+
+ call_kwargs: dict = {}
+
+ def _mock_chat(*args, **kwargs):
+ call_kwargs.update(kwargs)
+ return "【三月七】\nok\n\n【刻晴】\nok\n\n【纳西妲】\nok\n\n【流萤】\nok"
+
+ monkeypatch.setattr(wechat_generator, "chat", _mock_chat)
+ monkeypatch.setattr(wechat_generator, "load_role", lambda x: "")
+
+ result = wechat_generator.generate_wechat_opening(
+ role_hint="auto",
+ relationship_mode="standard",
+ performance_mode="fast",
+ selected_model="auto",
+ )
+
+ assert result
+ assert call_kwargs.get("max_tokens") is not None
+ assert call_kwargs.get("task_name") == "wechat_opening"