codebee 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
+ ## 未发布
8
+
9
+ ## v0.1.20(2026-09-20)
10
+
11
+ - 全类型交付契约:小说、文章、短视频、文档、翻译、调研、演讲、汇报、邮件、技术方案、简历按真实类型路由,并注入各自角色、成品结构和真实性约束
12
+ - token 预算修复:默认关闭压缩与续会话路径也累计真实 usage;压缩单次/重试防重复记账,预算熔断恢复有效
13
+ - 任务类型管理修复:15 种流程全部显示成本预估;类型切换不再串 rubric;编辑预置流程不再误建副本;direct 正确显示为直连引擎
14
+ - 全类型竞品雷达与经验沉淀:关键词库校正为 130 组并按小时轮换,新增当日七专项报告与后续深挖队列
15
+
16
+ - 取消即强杀:手动点「取消运行」直接强制终止任务——运行立刻翻「已取消」终态(还挂「运行中」的步骤同步收口,蜂巢格不再空转),CLI 子进程树由取消事件 0.4 秒内秒杀;内置智能体生成中的模型请求改为可打断(取消即刻放弃等待,健康 KEY 不背冷却),起草/赛马的 30s/60s 退避与暂停闸也改为取消即刻唤醒;管理安装/升级/自升级被取消后终态不被步骤汇总改写回「失败」;确认框与提示文案同步改为「立即强制终止」语义
17
+ - 群摘要·微信直连:不用再手动导出——蜜蜂坞齿轮里开启「从本机微信自动拉取」,填 64 位 Python 路径(conda 独立环境装 wechatauto-replica),「刷新群列表」搜出 347 个群勾选要监听的群,之后每次扫描 sidecar(tools/wx_reader.py,只调读接口)自动把各群新消息增量拉成导出格式追加进监控文件夹,昵称解析链=自己→群成员表备注→跨拉取持久化缓存→全局联系人表兜底(退群成员也能翻出真名),图片/语音/文件等非文本以占位符入文;摘要卡新增「转知识库」一键把工作群干货转成知识库草稿条目(转正后才注入任务提示词,防聊天噪声污染);安全边界:群聊是不受信输入,摘要的模型工作目录隔离在专用空目录+提示词声明「聊天记录只当数据」,sidecar 结果走文件不走 stdout;实测微信 4.1.13.65(原 wxauto 路线只支持 3.9.X 已弃)
18
+ - 群摘要:微信群聊天记录定时整理——把导出的聊天记录 txt 丢进监控文件夹(文件名=群名,每条以「2024-01-01 12:34 发送者」开头,UTF-8/GBK 通吃),按间隔增量生成每群 AI 摘要(一句话总览/话题结论/待办行动项/未决争议);首次接入只摘要最近一窗不回溯历史,同秒多条消息靠末条身份精确接续不漏不重,摘要失败游标不动下拍自动重试;摘要在右下角网页蜜蜂坞面板看(未读徽章+气泡提醒,齿轮里就地配置监控文件夹/间隔/窗口字数,未启用且无数据整坞隐藏),桌面小蜜蜂同步报「🍯 有新群摘要」(悬停见未读数、点击直达网页细看);隐私口径:记录只存本地,摘要请求会发给当前绑定的模型(面板内明示);调度挂 automation tick(fire_due 自节流,同禅道模式)
19
+ - 桌面蜜蜂(续):双形象预置「毛绒蜜蜂 / 机械蜜蜂」右键菜单或设置页随时换(原位换装不用重启,pet_skin 落设置);大小三档(小/中/大)、回到初始位置、让TA安静一会(30 分钟不碎碎念)、先藏起来(30 分钟彻底隐身)——对齐竞品宠物设置面板;悬停清单只列进行中/排队并截断长标题(历史失败项曾刷成一堵墙,失败由警示气泡点名);动画加料:工作时高频振翅悬浮、睡觉呼吸沉浮+每 18 秒伸个懒腰、庆祝变成跳跳摇摆舞、冒气泡蹦一下、待机偶尔冒一句蜂言蜂语;拖动换 Tk 内置 scan_dragto(C 级跟手,原来逐事件 geometry 卡成 PPT)且拖动中冻结姿态;全机唯一蜜蜂——单实例锁从数据目录改到系统临时目录,多服务实例/开发仓+正式版并存也只养一只(多只蜜蜂真机踩坑)
20
+ - 桌面蜜蜂:CodeBee 桌宠(参考 Codex Pets 产品形态)——tkinter 零依赖自绘小蜜蜂常驻桌面右下角,实时反映蜂群状态:空闲打盹(Zzz)、任务运行振翅悬停(徽章显示数量+步骤进度条)、全部完工翻滚庆祝(气泡「N 个任务完工!」)、失败/取消警示抖动(气泡点名问题任务);鼠标悬停弹出「蜂群动态」任务清单(进行中/排队/失败实时刷新,含当前步骤与进度);左键打开 CodeBee、右键菜单(显示模式/语言/关闭)、按住拖动且位置记忆;跑在独立小进程(pick_dialog 同款进程隔离),服务关闭约 1 分钟内自离不留孤儿;设置页「皮肤」子页新增开关与「常驻 / 仅任务运行时」显示模式(默认开、常驻);设置是唯一真源——关闭后蜜蜂下个轮询周期自离、看护线程不复活;单实例心跳锁防双开
21
+
22
+ ## v0.1.19(2026-09-20)
23
+
24
+ - 知识库零负担化:条目默认直接转正参与注入(提炼提示词自带质量约束兜底),历史草稿启动时一次性转正
25
+ - 发布管理器自愈:启动收尸把等扫码/忙状态的死线程改判 error;旧版冤判「等待登录超时」的记录后台复核,登录态还在的直接翻回已连接
26
+ - 禅道地址自适应:REST 根路径 404 时自动探测 /zentao 子路径部署并缓存,另提供产品/账号清单接口供前端下拉
27
+ - 新增竞品调研流程:抓取七猫+番茄排行榜公开数据,AI 提炼跨平台热门题材/人设/差异化切入点
28
+
7
29
  ## v0.1.18(2026-09-20)
8
30
 
9
31
  - 知识库:运行产出自动整理成可复用知识——编排者从产出材料(调研报告/文档)提炼知识条目(草稿态),人工「转正」后按任务类型+相关性自动注入同类任务的规划与评审提示词;与经验库分工(教训记「别这么做」,知识记「已知是这样」);设置页独立「知识库」子页,搜索/状态/标签过滤+手动新建(即个人知识管理库);事实带账龄,as_of 超 90 天注入时自动标注「可能过期」
package/README.md CHANGED
@@ -21,10 +21,11 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
21
21
  不会把你的任务内容交给任何第三方。
22
22
 
23
23
  <!-- relnotes:start -->
24
- ### 最新版更新内容(v0.1.18
24
+ ### 最新版更新内容(v0.1.20
25
25
 
26
- - 新增「知识库」:产出材料自动提炼成知识条目,转正后按任务类型自动注入规划与评审——你的个人知识管理库
27
- - 新增封面生成(智谱 CogView);七猫发布分类纠偏;管理操作排队补队治理
26
+ - 11 类内容任务按真实类型路由,并带各自角色、成品结构和真实性约束
27
+ - token 预算恢复有效:默认路径与续会话均累计真实 usage,压缩重试不重复记账
28
+ - 15 种流程成本预估全覆盖;修复 rubric 串值、预置流程误建副本和 direct 标签
28
29
  <!-- relnotes:end -->
29
30
 
30
31
  ---
@@ -91,8 +92,8 @@ OpenCode / Continue / Cursor / Trae 十种工具的配置,一次带入全部
91
92
 
92
93
  ### 更多内置能力
93
94
 
94
- - **14 种任务类型**:代码、小说、连载小说、文档、翻译、调研、演讲、短视频脚本、
95
- 周报、邮件、技术方案、简历,以及「直接执行」对话模式;全部支持自定义流程
95
+ - **15 种任务类型**:直接执行、代码、小说、连载小说、自媒体文章、短视频脚本、
96
+ 文档、翻译、扫榜选材、调研报告、演讲稿、工作汇报、商务邮件、技术方案、简历;全部支持自定义流程
96
97
  (引擎 / 评审维度 / 阈值 / 轮数 / 提示词覆盖);
97
98
  - **质量闸门与裁决**:评审失败≠0 分照过、降级大纲不开写、超时但已落盘的稿件照常
98
99
  送评审;需要人拍板的事项进「待裁决」队列(徽章 + 提示音),`tools/bee.py`
@@ -198,7 +199,8 @@ python tools\bee.py discard <task> :: 否决——删除任务分支(需输
198
199
  ## 任务类型与自定义流程
199
200
 
200
201
  主表单的「类型」下拉即流程列表,点「管理」可新增/编辑自定义流程(引擎、评审维度、
201
- 阈值、轮数、产出文件名、起草/评审提示词覆盖)。内置 14 种:
202
+ 阈值、轮数、产出文件名、起草/评审提示词覆盖)。内置 15 种;禅道工单是自动转成
203
+ `code` 修复任务的集成入口,不另占菜单类型:
202
204
 
203
205
  | 类型 | 引擎 | 流程 |
204
206
  |---|---|---|
@@ -206,13 +208,17 @@ python tools\bee.py discard <task> :: 否决——删除任务分支(需输
206
208
  | 💻 代码 | code | 实现 → 验证 → 跨厂商评审 → 自动修复/换将 |
207
209
  | 📖 小说 | review | 起草 → 多维评审(情节/人物/文笔/节奏/吸引力)→ 修订 → 发布门禁 |
208
210
  | 📚 连载小说 | review | 大纲 → 逐章起草/评审/修订 → 全局一致性评审 → 合并成书(断点续跑/继续连载) |
211
+ | 📰 自媒体文章 | review | 平台化标题/开头/结构 → 多维评审 → 修订 → 发布门禁 |
212
+ | 🎬 短视频脚本 | review | 画面 + 口播 + 字幕/音效 → 多维评审 → 修订 → 发布门禁 |
209
213
  | 📝 文档 | review | 起草 → 多维评审(准确性/结构/表达/实用)→ 修订 → 门禁 |
210
214
  | 🌐 翻译 | review | 起草 → 多维评审(忠实度/流畅度/术语/风格)→ 修订 → 门禁 |
215
+ | 📈 扫榜选材 | direct | 抓七猫 + 番茄公开榜单 → 跨平台题材/人设/切入点洞察 |
211
216
  | 🔍 调研报告 | review | 起草 → 多维评审(全面性/深度/论据/结论)→ 修订 → 门禁 |
212
217
  | 🎤 演讲稿 | review | 起草 → 多维评审(主题/结构/感染力/语言)→ 修订 → 门禁 |
213
- | 🎬 短视频脚本 / 📅 周报 / ✉️ 邮件 / 📋 技术方案 / 📄 简历 | review | 各自专属评审维度 |
218
+ | 📅 工作汇报 / ✉️ 商务邮件 / 📋 技术方案 / 📄 简历 | review | 各自专属角色、交付约束与评审维度 |
214
219
 
215
- 两种引擎:
220
+ 三种引擎:
221
+ - **direct 引擎**:单智能体直接执行目标,保留会话上下文,适合问答、探索和无需评审链的快捷任务;
216
222
  - **code 引擎**:实现者在工作目录直接改代码 → 执行验证命令(确定性结果)→ 跨厂商评审
217
223
  (结构化 JSON)→ 不通过则自动修复循环(至多 2 轮)→ 仍失败自动换将全量重实现;
218
224
  - **review 引擎**:作者起草产出文件 → 多个评审智能体按维度独立打分 + 提问题 →
@@ -394,6 +394,25 @@ def _tick():
394
394
  _zentao.fire_due()
395
395
  except Exception:
396
396
  log.debug("automation: 禅道扫描跳过", exc_info=True)
397
+ # 微信群摘要联动:监控文件夹增量扫描(fire_due 自节流,同上)。
398
+ try:
399
+ from . import wxdigest as _wxd
400
+ r = _wxd.fire_due()
401
+ if r and not r.get("skipped") and (r.get("made") or r.get("error")):
402
+ log.info("automation: 群摘要扫描完成 made=%s err=%s",
403
+ r.get("made"), r.get("error") or "-")
404
+ except Exception:
405
+ log.debug("automation: 群摘要扫描跳过", exc_info=True)
406
+ # 每日垃圾清理联动:启用且当天没清过才真跑(fire_due 自节流,同上)。
407
+ try:
408
+ from . import cleanup as _cleanup
409
+ r = _cleanup.fire_due()
410
+ if r and (r.get("freed") or r.get("errors")):
411
+ log.info("automation: 每日清理完成 释放 %.1f MB%s",
412
+ r.get("freed", 0) / 1048576.0,
413
+ (",%d 项失败" % len(r.get("errors") or [])) if r.get("errors") else "")
414
+ except Exception:
415
+ log.debug("automation: 每日清理跳过", exc_info=True)
397
416
 
398
417
 
399
418
  def _loop():
@@ -0,0 +1,470 @@
1
+ # -*- coding: utf-8 -*-
2
+ """整机数据备份:导出 / 导入(换电脑、换路径安装时的数据搬家)。
3
+
4
+ 备份包是一个 zip,布局:
5
+ manifest.json 清单(app/schema/旧路径/计数/未打包目录)
6
+ data/… 整个数据目录(见下方排除清单)
7
+ workspace/<相对默认保存路径的路径> 任务工作目录(手稿/成品文件——真正的劳动成果)
8
+
9
+ 排除不打包(易失或机器相关,导入后也无意义):
10
+ - 发布浏览器 profile(data/publish/profiles 迁移残留 + 家目录 ~/.codebee/
11
+ publish_profiles 本来就在数据目录外)——GB 级且登录态 cookie 与机器绑定;
12
+ - 日志(data 根 *.log)、pet.lock、chat_cache、pending(运行中信箱)、
13
+ exports/imports(备份模块自己的输出/暂存区)。
14
+ 运行过程日志(runs/*/steps/*.log)默认打包,include_logs=False 可去掉减小体积。
15
+
16
+ 导入路径重映射:任务记录/设置里存的是旧机器的绝对路径。manifest 记下旧
17
+ data 目录与旧默认保存路径,导入时把所有 JSON 值里以旧路径开头的字符串改写
18
+ 为新机器对应路径(长前缀优先,避免父子前缀互吞),工作目录文件按相对路径
19
+ 落到新默认保存路径下。导入前自动把当前小体积状态(根 *.json + tasks/*.json)
20
+ 备份到 <data>/imports/pre-import-<ts>.zip 供反悔。
21
+
22
+ 导入要求没有运行中/排队中的任务(记录与文件正被使用);完成后必须重启
23
+ 服务(各模块的内存态与 import 期绑定只认启动时的数据目录)。
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ import shutil
31
+ import time
32
+ import zipfile
33
+ from pathlib import Path
34
+
35
+ from . import paths, settings as settings_mod
36
+
37
+ _SEG_SPLIT = re.compile(r"[\\/]")
38
+ _DRIVE_RE = re.compile(r"^[A-Za-z]:")
39
+
40
+ MANIFEST_NAME = "manifest.json"
41
+ SCHEMA = 1
42
+ APP_ID = "codebee"
43
+
44
+ # 数据目录内不打包的一级目录 / 文件名(易失、缓存或备份模块自留地)
45
+ _EXCLUDE_TOP_DIRS = {"exports", "imports", "chat_cache", "pending"}
46
+ _EXCLUDE_TOP_FILES = {"pet.lock"}
47
+ # publish/profiles 是 GB 级浏览器 profile 迁移残留(登录态与机器绑定),不打包
48
+ _LOG_SUFFIXES = (".log", ".tmp")
49
+
50
+
51
+ def _app_version():
52
+ try:
53
+ pkg = paths.ROOT / "package.json"
54
+ return str(json.loads(pkg.read_text(encoding="utf-8")).get("version") or "")
55
+ except Exception:
56
+ return ""
57
+
58
+
59
+ def _iter_files(base: Path):
60
+ """yield (绝对路径, 相对 Path)。符号坏链等交给调用方逐文件兜底。"""
61
+ for cur, dirs, files in os.walk(base):
62
+ cur_p = Path(cur)
63
+ for f in files:
64
+ p = cur_p / f
65
+ try:
66
+ yield p, p.relative_to(base)
67
+ except ValueError:
68
+ continue
69
+
70
+
71
+ def _export_skip(rel: Path, include_logs=True):
72
+ """导出时是否跳过该相对路径(rel 相对 DATA_DIR)。"""
73
+ parts = rel.parts
74
+ if not parts:
75
+ return True
76
+ if parts[0] in _EXCLUDE_TOP_DIRS:
77
+ return True
78
+ if parts[0] == "publish" and len(parts) > 1 and parts[1] == "profiles":
79
+ return True
80
+ if parts[0] in _EXCLUDE_TOP_FILES:
81
+ return True
82
+ if rel.suffix.lower() in _LOG_SUFFIXES:
83
+ # 运行过程日志按开关保留;数据根的服务日志一律不打包(易失)
84
+ if not (include_logs and parts[0] == "runs"):
85
+ return True
86
+ return False
87
+
88
+
89
+ def _collect_workspace_dirs():
90
+ """收集该打包的任务工作目录。
91
+
92
+ 只打包「默认保存路径」下的目录(当初由默认路径自动放置的 app 管辖区,
93
+ 与 store.migrate_task_workdirs 同一口径);落在数据目录里的(如 demo)
94
+ 随 data/ 打包;两者之外的是用户自己的目录(代码仓库等),不打包、只在
95
+ manifest.external_workdirs 里点名提醒。
96
+ 返回 (根目录, [相对 Path…], [外部目录…])。
97
+ """
98
+ root = Path(settings_mod.default_workdir())
99
+ from . import store
100
+ wds = set()
101
+ try:
102
+ for t in store.list_tasks(limit=10 ** 6):
103
+ wd = str(t.get("workdir") or "").strip()
104
+ if wd:
105
+ wds.add(Path(wd))
106
+ except Exception:
107
+ pass
108
+ try:
109
+ from . import automation
110
+ for at in automation.list_tasks():
111
+ wd = str(at.get("workdir") or "").strip()
112
+ if wd:
113
+ wds.add(Path(wd))
114
+ except Exception:
115
+ pass
116
+ inside, external = [], []
117
+ seen = set()
118
+ for wd in sorted(wds, key=lambda p: str(p).lower()):
119
+ s = str(wd)
120
+ if s.lower() in seen:
121
+ continue
122
+ seen.add(s.lower())
123
+ try:
124
+ rel = wd.resolve().relative_to(root.resolve())
125
+ except (ValueError, OSError):
126
+ try:
127
+ wd.resolve().relative_to(Path(paths.DATA_DIR).resolve())
128
+ continue # 数据目录内的随 data/ 打包,不重复收
129
+ except (ValueError, OSError):
130
+ external.append(s)
131
+ continue
132
+ if wd.is_dir():
133
+ inside.append(rel)
134
+ return root, inside, external
135
+
136
+
137
+ # ---------------------------------------------------------------- 导出
138
+
139
+ def export_data(target_dir="", include_logs=True, include_workspace=True):
140
+ """打包当前数据目录 + 任务工作目录到 zip。返回信息 dict;参数非法抛 ValueError。"""
141
+ data_dir = Path(paths.DATA_DIR)
142
+ if not data_dir.is_dir():
143
+ raise ValueError("数据目录不存在: %s" % data_dir)
144
+ target = Path(target_dir).expanduser() if str(target_dir or "").strip() \
145
+ else (Path.home() / "Downloads")
146
+ if not target.is_absolute():
147
+ raise ValueError("导出位置必须是绝对路径")
148
+ try:
149
+ target.mkdir(parents=True, exist_ok=True)
150
+ except OSError as e:
151
+ raise ValueError("导出位置不可创建: %s(%s)" % (target, e))
152
+
153
+ root, ws_rels, external = _collect_workspace_dirs()
154
+ manifest = {
155
+ "app": APP_ID, "schema": SCHEMA,
156
+ "exported_at": time.strftime("%Y-%m-%d %H:%M:%S"),
157
+ "version": _app_version(),
158
+ "data_dir": str(data_dir),
159
+ "default_workdir": str(root),
160
+ "include_logs": bool(include_logs),
161
+ "include_workspace": bool(include_workspace),
162
+ "external_workdirs": external,
163
+ "workspace_dirs": [str(r) for r in ws_rels],
164
+ }
165
+ out = target / ("codebee-backup-" + time.strftime("%Y%m%d-%H%M%S") + ".zip")
166
+ counts = {"data_files": 0, "workspace_files": 0, "tasks": 0, "runs": 0}
167
+ warnings = []
168
+ try:
169
+ counts["tasks"] = sum(1 for _ in (data_dir / "tasks").glob("t-*.json"))
170
+ rdir = data_dir / "runs"
171
+ counts["runs"] = sum(1 for _ in rdir.iterdir()) if rdir.is_dir() else 0
172
+ except OSError:
173
+ pass
174
+ manifest["counts"] = counts
175
+
176
+ zf = None
177
+ try:
178
+ zf = zipfile.ZipFile(str(out), "w", zipfile.ZIP_DEFLATED)
179
+ for p, rel in _iter_files(data_dir):
180
+ if _export_skip(rel, include_logs=include_logs) or p == out:
181
+ continue
182
+ try:
183
+ zf.write(str(p), str(Path("data") / rel))
184
+ counts["data_files"] += 1
185
+ except OSError as e:
186
+ warnings.append("跳过 %s(%s)" % (rel, e.strerror or e))
187
+ if include_workspace:
188
+ for rel in ws_rels:
189
+ base = root / rel
190
+ for p, frel in _iter_files(base):
191
+ try:
192
+ zf.write(str(p), str(Path("workspace") / rel / frel))
193
+ counts["workspace_files"] += 1
194
+ except OSError as e:
195
+ warnings.append("跳过 %s(%s)" % (rel / frel, e.strerror or e))
196
+ # manifest 最后写:此刻 data/workspace 计数才是终值
197
+ zf.writestr(MANIFEST_NAME, json.dumps(manifest, ensure_ascii=False, indent=2))
198
+ finally:
199
+ if zf is not None:
200
+ zf.close()
201
+ manifest["counts"] = counts
202
+ return {"path": str(out), "size": out.stat().st_size,
203
+ "tasks": counts["tasks"], "runs": counts["runs"],
204
+ "data_files": counts["data_files"],
205
+ "workspace_files": counts["workspace_files"],
206
+ "external_workdirs": external, "warnings": warnings}
207
+
208
+
209
+ # ---------------------------------------------------------------- 预览
210
+
211
+ def _read_manifest(zf):
212
+ try:
213
+ raw = zf.read(MANIFEST_NAME)
214
+ except KeyError:
215
+ raise ValueError("不是 CodeBee 备份包(缺少 manifest.json)")
216
+ try:
217
+ m = json.loads(raw.decode("utf-8"))
218
+ except Exception:
219
+ raise ValueError("备份包 manifest.json 无法解析")
220
+ if not isinstance(m, dict) or m.get("app") != APP_ID:
221
+ raise ValueError("不是 CodeBee 备份包(manifest 不匹配)")
222
+ if int(m.get("schema") or 0) > SCHEMA:
223
+ raise ValueError("备份包版本较新(schema %s > %d),请先升级 CodeBee 再导入"
224
+ % (m.get("schema"), SCHEMA))
225
+ return m
226
+
227
+
228
+ def _safe_join(base: Path, name: str):
229
+ """zip 条目名 → base 下安全路径(zip slip 四道闸,拒绝一切逃逸形态)。
230
+
231
+ ①条目名含 .. 字面量即拒绝(任何形态的父目录逃逸,构造路径之前完成);
232
+ ②按 / 与 \\ 切段后出现 .. 段、盘符(C:)同样拒绝;③只用纯段名 join 回
233
+ 基线(base / 带盘符的 name 在 Windows 会整体替换掉 base,故绝不直接拼);
234
+ ④normpath 后必须仍在 base 之内(双保险)。"""
235
+ if ".." in name:
236
+ raise ValueError("备份包含非法路径条目: %s" % name[:120])
237
+ parts = [p for p in _SEG_SPLIT.split(name) if p not in ("", ".")]
238
+ if (not parts or any(p == ".." for p in parts)
239
+ or _DRIVE_RE.match(parts[0])):
240
+ raise ValueError("备份包含非法路径条目: %s" % name[:120])
241
+ resolved = Path(os.path.normpath(os.path.join(str(base), *parts)))
242
+ try:
243
+ resolved.relative_to(base)
244
+ except ValueError:
245
+ raise ValueError("备份包含非法路径条目: %s" % name[:120])
246
+ return resolved
247
+
248
+
249
+ def _busy_runs():
250
+ from . import store
251
+ out = []
252
+ try:
253
+ for r in store.list_runs(limit=10 ** 6):
254
+ if r.get("status") in ("queued", "running"):
255
+ out.append(r.get("id"))
256
+ except Exception:
257
+ pass
258
+ return out
259
+
260
+
261
+ def inspect_backup(zip_path):
262
+ """解析备份包,返回预览信息(不落任何盘)。包打不开/不是本家包抛 ValueError。"""
263
+ p = Path(str(zip_path or "").strip())
264
+ if not p.is_file():
265
+ raise ValueError("备份包不存在: %s" % p)
266
+ try:
267
+ zf = zipfile.ZipFile(str(p))
268
+ except Exception as e:
269
+ raise ValueError("备份包打不开:%s" % e)
270
+ with zf:
271
+ m = _read_manifest(zf)
272
+ names = zf.namelist()
273
+ data_n = sum(1 for n in names if n.startswith("data/"))
274
+ ws_n = sum(1 for n in names if n.startswith("workspace/"))
275
+ old_data = str(m.get("data_dir") or "")
276
+ old_wd = str(m.get("default_workdir") or "")
277
+ cur_data = str(Path(paths.DATA_DIR))
278
+ cur_wd = settings_mod.default_workdir()
279
+ return {
280
+ "exported_at": m.get("exported_at"), "version": m.get("version"),
281
+ "tasks": (m.get("counts") or {}).get("tasks"),
282
+ "runs": (m.get("counts") or {}).get("runs"),
283
+ "data_files": data_n, "workspace_files": ws_n,
284
+ "include_logs": bool(m.get("include_logs", True)),
285
+ "external_workdirs": m.get("external_workdirs") or [],
286
+ "old_data_dir": old_data, "old_workdir": old_wd,
287
+ "cur_data_dir": cur_data, "cur_workdir": cur_wd,
288
+ "remap_needed": (old_data.lower() != cur_data.lower()
289
+ or old_wd.lower() != cur_wd.lower()),
290
+ "busy_runs": _busy_runs(),
291
+ "same_machine": old_data.lower() == cur_data.lower(),
292
+ }
293
+
294
+
295
+ # ---------------------------------------------------------------- 导入
296
+
297
+ def _pre_import_backup(ts):
298
+ """把当前小体积状态备份到 <data>/imports/pre-import-<ts>.zip(反悔用)。"""
299
+ data_dir = Path(paths.DATA_DIR)
300
+ bdir = data_dir / "imports"
301
+ bdir.mkdir(parents=True, exist_ok=True)
302
+ out = bdir / ("pre-import-" + ts + ".zip")
303
+ with zipfile.ZipFile(str(out), "w", zipfile.ZIP_DEFLATED) as zf:
304
+ for p in sorted(data_dir.glob("*.json")):
305
+ try:
306
+ zf.write(str(p), p.name)
307
+ except OSError:
308
+ pass
309
+ tdir = data_dir / "tasks"
310
+ if tdir.is_dir():
311
+ for p in sorted(tdir.glob("*.json")):
312
+ try:
313
+ zf.write(str(p), str(Path("tasks") / p.name))
314
+ except OSError:
315
+ pass
316
+ return out
317
+
318
+
319
+ def _remap_value(v, pairs):
320
+ """字符串值按前缀对改写(长前缀优先);命中返回 (新串, True)。"""
321
+ if not isinstance(v, str) or not v:
322
+ return v, False
323
+ for old, new in pairs: # pairs 已按 len(old) 降序
324
+ if old.lower() == v.lower():
325
+ return new, True
326
+ lo = v.lower()
327
+ if lo.startswith(old.lower()):
328
+ tail = v[len(old):]
329
+ if tail and tail[0] not in "\\/":
330
+ continue # 只是碰巧同前缀(如 E:\GoOut2),不是旧路径
331
+ return new + tail, True
332
+ return v, False
333
+
334
+
335
+ def _remap_obj(o, pairs, stat):
336
+ if isinstance(o, dict):
337
+ return {k: _remap_obj(v, pairs, stat) for k, v in o.items()}
338
+ if isinstance(o, list):
339
+ return [_remap_obj(v, pairs, stat) for v in o]
340
+ nv, hit = _remap_value(o, pairs)
341
+ if hit:
342
+ stat[0] += 1
343
+ return nv
344
+
345
+
346
+ def _remap_all_json(pairs):
347
+ """数据目录内所有 JSON 的字符串值做旧→新路径改写。返回 (文件数, 值数)。"""
348
+ data_dir = Path(paths.DATA_DIR)
349
+ files = hits = 0
350
+ for p in data_dir.rglob("*.json"):
351
+ rel = p.relative_to(data_dir)
352
+ if rel.parts[0] in ("imports", "exports"):
353
+ continue
354
+ try:
355
+ raw = p.read_text(encoding="utf-8")
356
+ obj = json.loads(raw)
357
+ if not isinstance(obj, (dict, list)):
358
+ continue
359
+ stat = [0]
360
+ new_obj = _remap_obj(obj, pairs, stat)
361
+ if stat[0]:
362
+ tmp = p.with_suffix(".tmp")
363
+ tmp.write_text(json.dumps(new_obj, ensure_ascii=False, indent=2),
364
+ encoding="utf-8")
365
+ os.replace(str(tmp), str(p))
366
+ files += 1
367
+ hits += stat[0]
368
+ except Exception:
369
+ continue # 单个文件坏/被锁不阻塞整体导入
370
+ return files, hits
371
+
372
+
373
+ def _extract(zf, names, base, prefix, skip=None):
374
+ n = 0
375
+ for name in names:
376
+ if not name.startswith(prefix) or name == prefix:
377
+ continue
378
+ rel = name[len(prefix):]
379
+ if not rel:
380
+ continue
381
+ if skip and skip(rel):
382
+ continue
383
+ target = _safe_join(base, rel)
384
+ if name.endswith("/"):
385
+ target.mkdir(parents=True, exist_ok=True)
386
+ continue
387
+ target.parent.mkdir(parents=True, exist_ok=True)
388
+ with zf.open(name) as src, open(str(target), "wb") as dst:
389
+ shutil.copyfileobj(src, dst)
390
+ n += 1
391
+ return n
392
+
393
+
394
+ def apply_import(zip_path, mode="merge", remap=True, include_workspace=True):
395
+ """应用备份包。mode=merge 现有数据保留、同名覆盖;replace 先清空再落(不碰
396
+ imports/ 与 pet.lock)。返回信息 dict;校验失败/有运行中任务抛 ValueError。"""
397
+ p = Path(str(zip_path or "").strip())
398
+ if not p.is_file():
399
+ raise ValueError("备份包不存在: %s" % p)
400
+ mode = mode if mode in ("merge", "replace") else "merge"
401
+ busy = _busy_runs()
402
+ if busy:
403
+ raise ValueError("有 %d 个任务正在运行或排队(%s…),导入会踩到正在写的"
404
+ "文件;等它们跑完或取消后再试"
405
+ % (len(busy), "、".join(busy[:3])))
406
+
407
+ cur_wd = settings_mod.default_workdir()
408
+ data_dir = Path(paths.DATA_DIR)
409
+ zf = zipfile.ZipFile(str(p))
410
+ try:
411
+ m = _read_manifest(zf)
412
+ names = [n for n in zf.namelist() if not n.endswith("/")]
413
+ # 事前把全部条目做 zip-slip 校验,任何一个不合法就整体拒绝
414
+ for name in names:
415
+ if name.startswith("data/"):
416
+ _safe_join(data_dir, name[len("data/"):])
417
+ elif name.startswith("workspace/"):
418
+ _safe_join(Path(cur_wd), name[len("workspace/"):])
419
+ elif name != MANIFEST_NAME:
420
+ raise ValueError("备份包含无法识别的条目: %s" % name)
421
+ ts = time.strftime("%Y%m%d-%H%M%S")
422
+ backup_path = _pre_import_backup(ts)
423
+ if mode == "replace":
424
+ for child in data_dir.iterdir():
425
+ if child.name in ("imports", "pet.lock"):
426
+ continue
427
+ try:
428
+ if child.is_dir():
429
+ shutil.rmtree(str(child), ignore_errors=True)
430
+ else:
431
+ child.unlink()
432
+ except OSError:
433
+ pass
434
+ n_data = _extract(zf, names, data_dir, "data/")
435
+ n_ws = 0
436
+ if include_workspace:
437
+ Path(cur_wd).mkdir(parents=True, exist_ok=True)
438
+ n_ws = _extract(zf, names, Path(cur_wd), "workspace/")
439
+ finally:
440
+ zf.close()
441
+
442
+ # 路径重映射:长前缀优先(工作目录可能在数据目录之下,避免被短前缀截走)
443
+ old_data = str(m.get("data_dir") or "")
444
+ old_wd = str(m.get("default_workdir") or "")
445
+ pairs = []
446
+ if remap:
447
+ for old, new in ((old_wd, cur_wd), (old_data, str(data_dir))):
448
+ if old and old.lower() != new.lower():
449
+ pairs.append((old, new))
450
+ pairs.sort(key=lambda x: len(x[0]), reverse=True)
451
+ remapped_files = remapped_values = 0
452
+ if pairs:
453
+ remapped_files, remapped_values = _remap_all_json(pairs)
454
+
455
+ # 导入的 settings.json 里若带着旧机器默认路径,上面已重映射;把当前生效值对齐
456
+ try:
457
+ from . import store
458
+ with store.LOCK:
459
+ store._TASKS.clear()
460
+ store._RUNS.clear()
461
+ store.load_all()
462
+ store.bump_state()
463
+ except Exception:
464
+ pass
465
+ return {"mode": mode, "data_files": n_data, "workspace_files": n_ws,
466
+ "remap": {"applied": bool(pairs), "files": remapped_files,
467
+ "values": remapped_values, "pairs": [[o, n] for o, n in pairs]},
468
+ "backup_path": str(backup_path),
469
+ "external_workdirs": m.get("external_workdirs") or [],
470
+ "restart_required": True}
@@ -426,11 +426,14 @@ def _resolve_author(task):
426
426
 
427
427
  def make_book_meta(task, platform, author_agent=None, log_path=None):
428
428
  """生成一个平台的作品信息。返回 (meta dict, source 字符串);模板兜底永远有返回。"""
429
- from . import modelhub, planner, runner, skills # 惰性导入,同 planner
429
+ from . import knowledge, modelhub, planner, runner, skills # 惰性导入,同 planner
430
430
  material, outline = _collect_material(task)
431
431
  schema = {"fanqie": FANQIE_SCHEMA, "qimao": QIMAO_SCHEMA}.get(platform) or ""
432
432
  plat_label = PLATFORMS[platform]["label"]
433
433
  sk_block, _ = skills.block_for(task)
434
+ kb_block = knowledge.block_for(task)
435
+ if kb_block:
436
+ sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
434
437
  goal = task.get("goal") or ""
435
438
  # 真实选项注入:分类/标签表按平台与读者频段给全(模型只能从表里选)
436
439
  options = (_fq_options_block(goal) if platform == "fanqie" else _qm_options_block(goal))
@@ -16,6 +16,7 @@ import io
16
16
  import json
17
17
  import mimetypes
18
18
  import os
19
+ import threading
19
20
  from pathlib import Path
20
21
 
21
22
  from . import modelhub, runner
@@ -321,6 +322,32 @@ def _post_json(url, headers, body, allow_private, timeout):
321
322
  return modelhub._post_json_http(url, headers, body, allow_private, timeout=timeout)
322
323
 
323
324
 
325
+ def _post_interruptible(url, headers, body, allow_private, timeout, cancel_event):
326
+ """可打断的模型调用:cancel_event 置位即刻放弃等待返回。
327
+
328
+ 单次生成最长可跑满 timeout,取消不能陪跑到自然结束——HTTP 交给守护
329
+ 线程自行收尾(响应被丢弃,socket 随线程结束释放),主流程立即返回。
330
+ 服务端仍会把这次生成跑完(token 已花),但任务本身即刻终止。"""
331
+ box = {}
332
+
333
+ def _go():
334
+ try:
335
+ box["r"] = _post_json(url, headers, body, allow_private, timeout)
336
+ except Exception as e:
337
+ box["r"] = (0, None, repr(e))
338
+
339
+ th = threading.Thread(target=_go, daemon=True)
340
+ th.start()
341
+ if cancel_event is None:
342
+ th.join(timeout + 10)
343
+ else:
344
+ while th.is_alive() and not cancel_event.wait(0.5):
345
+ pass
346
+ if cancel_event is not None and cancel_event.is_set():
347
+ return 0, None, "已取消"
348
+ return box.get("r") or (0, None, "无响应")
349
+
350
+
324
351
  def _m_openai(m):
325
352
  """内部消息 → openai 消息。tool_results: [(call_id, 结果文本)]。"""
326
353
  if m["role"] == "assistant":
@@ -507,7 +534,12 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
507
534
  for kk in keys:
508
535
  url, headers, body = _build_request(proto, pbase, kk["key"], model,
509
536
  system, msgs, tools_ok)
510
- status, data, err = _post_json(url, headers, body, allow_private, timeout)
537
+ status, data, err = _post_interruptible(url, headers, body,
538
+ allow_private, timeout,
539
+ cancel_event)
540
+ if cancel_event is not None and cancel_event.is_set():
541
+ # 取消先于一切记账:健康 KEY 不能因被放弃的请求背上冷却
542
+ return _fail("已取消")
511
543
  if status == 0 or not (200 <= status < 300):
512
544
  msg = ""
513
545
  if isinstance(data, dict):