codebee 0.1.25 → 0.1.27

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
@@ -6,6 +6,28 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
6
6
 
7
7
  ## 未发布
8
8
 
9
+ ## v0.1.27(2026-09-21)
10
+
11
+ ### ✨ 新功能
12
+
13
+ - 连载任务防穿帮:新增「资源账本」——每章评审自动登记新出现的道具、伤情、承诺与伏笔,写下一章前自动注入提醒核对,角色不会上一章断臂下一章就挥剑。
14
+ - 连载评审逐项核对:每章按大纲要点逐项判定「已完成/未完成/待核实」并引用正文证据,铺垫不再被当成已完成。
15
+ - 新建任务记住你的偏好:上次选择的任务类型、轮数、阈值、章节数等下次自动预填(只记参数,不记内容)。
16
+
17
+ ### 🛠 问题修复
18
+
19
+ - 代码任务提速:验证命令失败时先直接修复再评审(首轮不再白跑一次评审),连续失败两轮后才请评审参与诊断。
20
+
21
+ ## v0.1.26(2026-09-21)
22
+
23
+ ### ✨ 新功能
24
+
25
+ - 顶栏新增统一操作状态中心:创建、删除、绑定、插件等用户写操作会显示进行中、完成或失败状态,并保留时间与错误原因,慢请求不再像页面卡死。
26
+
27
+ ### 🛠 问题修复
28
+
29
+ - 后台维护写请求、任务澄清探测和轮询请求默认静默,避免把后台动作误显示成用户操作;移动端状态弹层自适应屏幕并支持减少动画。
30
+
9
31
  ## v0.1.25(2026-09-21)
10
32
 
11
33
  ### ✨ 新功能
package/README.md CHANGED
@@ -23,6 +23,27 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
23
23
  <!-- relnotes:start -->
24
24
  ### ✨ 新功能 · New
25
25
 
26
+ - 连载任务防穿帮:新增「资源账本」——每章评审自动登记新出现的道具、伤情、承诺与伏笔,写下一章前自动注入提醒核对。
27
+ - Serial tasks now keep a resource ledger: props, injuries, promises, and plot threads are tracked per chapter and injected before writing the next one.
28
+
29
+ - 连载评审逐项核对:每章按大纲要点逐项判定「已完成/未完成/待核实」并引用正文证据,铺垫不再被当成已完成。
30
+ - Chapter reviews now check every outline beat against the actual text — foreshadowing no longer counts as done.
31
+
32
+ - 新建任务记住你的偏好:上次选择的任务类型、轮数、阈值、章节数等下次自动预填(只记参数,不记内容)。
33
+ - The new-task form now remembers your last-used type, rounds, threshold, and chapter settings (parameters only, never content).
34
+
35
+ ### 🛠 问题修复 · Fixes
36
+
37
+ - 代码任务提速:验证命令失败时先直接修复再评审(首轮不再白跑一次评审),连续失败两轮后才请评审参与诊断。
38
+ - Code tasks get faster: when verification fails, the agent fixes first and review joins only after repeated failures.
39
+
40
+ ### 上一版亮点 · Previous release
41
+
42
+ - 顶栏新增统一操作状态中心:创建、删除、绑定、插件等写操作会显示进行中、完成或失败状态,并保留时间与错误原因。
43
+ - A unified operation center now shows create, delete, binding, and plugin actions as running, completed, or failed, with timestamps and error details.
44
+
45
+ ### 上一版亮点 · Previous release
46
+
26
47
  - 文档类任务更专业:汇报先给结论、邮件先答复再铺陈、技术方案从问题开场并保留被否决的真实备选、文档验收标准可逐条勾选。
27
48
  - Smarter document tasks: reports lead with conclusions, emails answer first, proposals open at the problem and keep a real rejected alternative, docs get testable acceptance criteria.
28
49
 
@@ -2784,6 +2784,7 @@ def test_model(provider_id, model_name, key_id=""):
2784
2784
  # 这类网关两种 wire 挂在不同路径,同 base 探测必 404,只能按已知映射补候选。
2785
2785
  _KNOWN_WIRE_BASES = (
2786
2786
  (("api.z.ai",), "/api/anthropic", "https://api.z.ai/api/paas/v4"),
2787
+ (("open.bigmodel.cn",), "/api/anthropic", "https://open.bigmodel.cn/api/paas/v4"),
2787
2788
  )
2788
2789
 
2789
2790
 
@@ -1091,10 +1091,25 @@ def _run_code(run, task, agents, ev, stats, mode):
1091
1091
 
1092
1092
  def review_and_score():
1093
1093
  verify_pass, verify_ran = _run_verify(run_id, task, workdir, ev)
1094
+ # gate 验证(借鉴 pi-subagents 的 gate:"npm test"):verify 失败时先跳过
1095
+ # 模型评审直接进修复轮——评审此时只能复述「验证没过」,白烧一次调用。
1096
+ # 连续失败 >=2 轮后恢复评审参与诊断(模型能看出编译错误之外的病灶)。
1097
+ if not verify_pass and verify_ran:
1098
+ verify_fail_streak[0] += 1
1099
+ if verify_fail_streak[0] < 2:
1100
+ return {"pass": False, "scores": {},
1101
+ "issues": [{"severity": "high", "title": "验证命令未通过",
1102
+ "detail": "验证命令 `%s` 未通过:先修复使验证转绿,无需等评审。"
1103
+ % task.get("verify_command")}],
1104
+ "summary": "gate:验证未通过,跳过模型评审直接修复"}, verify_pass, verify_ran
1105
+ else:
1106
+ verify_fail_streak[0] = 0
1094
1107
  # 先让确定性验证落盘,再执行模型评审;终态判断会同时使用两份证据。
1095
1108
  review_json = _run_review(run_id, task, workdir, modelhub.bind_agent(reviewer, difficulty), ev)
1096
1109
  return review_json, verify_pass, verify_ran
1097
1110
 
1111
+ verify_fail_streak = [0] # gate:verify 连败计数(>=2 轮恢复评审参与诊断)
1112
+
1098
1113
  attempt_note = route.get("implementer", "") if mode == "auto" else ""
1099
1114
  round_no = 0
1100
1115
  review_json, verify_pass, verify_ran = None, True, False
@@ -1707,6 +1722,43 @@ def _plot_modules(workdir):
1707
1722
  "鼓励化用,不要照抄原句)\n\n" + txt)
1708
1723
 
1709
1724
 
1725
+ LEDGER_FILE = os.path.join(".codebee", "resource-ledger.md")
1726
+ _LEDGER_MAX_CHARS = 6000 # 账本注入上限:太老的状态让评审官收敛 recent 优先
1727
+
1728
+
1729
+ def _parse_tagged_lines(text, tag):
1730
+ """从模型回复提取 <tag>...</tag> 块的非空行列表(缺失/空块返回 [])。"""
1731
+ m = re.search(r"<%s>([\s\S]*?)</%s>" % (tag, tag), text or "")
1732
+ if not m:
1733
+ return []
1734
+ return [ln.strip(" -*") for ln in m.group(1).splitlines() if ln.strip(" -*")]
1735
+
1736
+
1737
+ def _append_ledger(workdir, chapter, lines):
1738
+ """资源账本追加:本章评审提炼的道具/伤情/承诺/伏笔增量,供下章起草注入。"""
1739
+ p = os.path.abspath(os.path.join(str(workdir or ""), LEDGER_FILE))
1740
+ if not _inside(workdir, p):
1741
+ raise ValueError("ledger 路径越界")
1742
+ os.makedirs(os.path.dirname(p), exist_ok=True)
1743
+ with open(p, "a", encoding="utf-8") as f:
1744
+ if f.tell() == 0:
1745
+ f.write("# 资源账本(每章评审自动追加:道具/伤情/承诺/伏笔的现状,"
1746
+ "写新章前必须核对,防止跨章穿帮)\n\n")
1747
+ f.write("## 第 %d 章\n%s\n" % (chapter, "\n".join("- " + x for x in lines)))
1748
+
1749
+
1750
+ def _read_ledger(workdir):
1751
+ """读取资源账本注入文本(截至上一章)。不存在返回「(暂无记录)」。"""
1752
+ p = os.path.abspath(os.path.join(str(workdir or ""), LEDGER_FILE))
1753
+ if not _inside(workdir, p) or not os.path.isfile(p):
1754
+ return "(暂无记录,本章建立的新道具/伤情/承诺/伏笔会被账本自动登记)"
1755
+ try:
1756
+ txt = _read_text_any_enc(p)[-_LEDGER_MAX_CHARS:].strip()
1757
+ except OSError:
1758
+ return "(暂无记录)"
1759
+ return txt or "(暂无记录)"
1760
+
1761
+
1710
1762
  def _shrink_context_block(sk_block, bible, budget=12000):
1711
1763
  """分层上下文降级(长提示词在容量受限通道上会挂起/秒拒,2026-09-17 讯飞实测)。
1712
1764
 
@@ -1793,6 +1845,9 @@ __OUTLINE__
1793
1845
  ## 前情提要(此前各章结尾摘录,衔接用)
1794
1846
  __PREV__
1795
1847
 
1848
+ ## 资源账本(道具/伤情/承诺/伏笔的现状登记,写本章前必须核对)
1849
+ __LEDGER__
1850
+
1796
1851
  - 写完文件后,最终回复只输出一行:`第 __I__ 章完成(约 __WORDS__ 字)`——不要在回复里复述或解释正文。"""
1797
1852
 
1798
1853
  SERIAL_REVISE_PROMPT = """你是一名网文作者。第 __I__ 章没有通过评审,请修订文件 `__FILE__`(直接改写该文件)。文件必须以 UTF-8 编码保存(PowerShell 显式加 -Encoding UTF8,禁止依赖默认编码)。
@@ -1992,12 +2047,15 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1992
2047
  bible = task["context"] + ("\n\n" + bible if bible else "")
1993
2048
 
1994
2049
 
1995
- def crit_prompt_for(text, note=""):
2050
+ def crit_prompt_for(text, note="", event_check=""):
1996
2051
  tpl = _ensure_critique_placeholders(
1997
2052
  _tpl(task, "critique_prompt", NOVEL_CRITIQUE_PROMPT))
1998
2053
  if note:
1999
2054
  tpl = tpl.replace("你是严格的评审",
2000
2055
  "你是严格的评审(背景:%s,请结合全书目标评审本章节)" % note, 1)
2056
+ if event_check:
2057
+ # 逐项目标审稿(借鉴 AI-Novel-Writer v1.1):本章大纲要点逐项核对
2058
+ tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % event_check, 1)
2001
2059
  # stable_order:评审分轮次调用,hits 中途变化会打碎前缀缓存(§07 T1.2')
2002
2060
  sk, _ = skills.block_for(task, stable_order=True)
2003
2061
  if sk:
@@ -2074,6 +2132,22 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2074
2132
  i = start + k - 1 # 全书章号:文件名/步骤角色/评分记录都按全书编号
2075
2133
  ch = outline["chapters"][k - 1]
2076
2134
  ch_file = "chapter-%02d.md" % i
2135
+ # 逐项目标审稿(借鉴 AI-Novel-Writer):本章大纲要点随评审下发,评审
2136
+ # 以 <event_check> 回逐项判定;资源账本(借鉴角色资源账本)以 <ledger>
2137
+ # 回本章道具/伤情/承诺/伏笔增量,评审达标后追加账本文件供下章注入。
2138
+ event_block = (
2139
+ "## 本章大纲核对(逐项目标审稿)\n"
2140
+ "本章按大纲应完成:\n- 剧情要点:%s\n- 章末钩子:%s\n"
2141
+ "评审时逐项判定「已完成 / 未完成 / 待核实」,判定必须引用正文证据"
2142
+ "(原文短句或位置),写在回复末尾的 <event_check> 块内(每项一行)。"
2143
+ "「铺垫了但没发生」不算已完成;未完成的项必须反映到对应维度评分。\n"
2144
+ "另在 <event_check> 块之后输出 <ledger> 块(没有新变化就整个省略):"
2145
+ "逐行列出本章新出现或状态变化的 道具/伤情/承诺/伏笔,格式:"
2146
+ "类型|名称|现状(一句话)。\n\n"
2147
+ % (ch.get("beats") or "按大纲推进", ch.get("hook") or "留下悬念"))
2148
+ ev_check_lines = [[]] # 每章重置:第一份非空评审的逐项判定
2149
+ ledger_lines = [] # 本章全部评审的账本增量并集
2150
+ ledger_txt = _read_ledger(workdir) # 截至上一章的资源账本(起草注入)
2077
2151
  prev = ""
2078
2152
  draft_sid = "" # §07 T1.1:本轮 draft/复用章的会话 id(revise 复用;reuse 时为空)
2079
2153
  if i > 1:
@@ -2101,8 +2175,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2101
2175
  rounds_used = 1
2102
2176
  means = {}
2103
2177
 
2104
- def run_critique(text, rnd, note_extra="", critic_sids=None):
2105
- """一轮多维评审:返回 (cj_by_agent, scored)。变体赛马与主循环共用。"""
2178
+ # 逐项目标审稿 + 资源账本的每章聚合桶(run_critique 内解析填充;
2179
+ # event_check 取第一份非空,ledger 全评审增量求并)
2180
+ ev_check_lines = [[]]
2181
+ ledger_lines = []
2182
+
2183
+ def run_critique(text, rnd, note_extra="", critic_sids=None, event_check=""):
2184
+ """一轮多维评审:返回 (cj_by_agent, scored)。变体赛马与主循环共用。
2185
+ event_check:本章大纲核对块(逐项目标审稿),随评审下发并回收标记块。"""
2106
2186
  cj_map, sids = {}, dict(critic_sids or {})
2107
2187
  scored = 0 # 真正给出分数的评审数;失败/不可解析不得当成 0 分计入
2108
2188
  for agent in critics:
@@ -2125,12 +2205,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2125
2205
  note=("小说第 %d 章" % i) + (
2126
2206
  "|你的专属评审视角:%s(其他评审会覆盖其余视角,"
2127
2207
  "请深挖你的镜头,但所有维度仍需打分)" % lens)
2128
- if lens else "") + note_extra,
2208
+ if lens else "",
2209
+ event_check=event_check) + note_extra,
2129
2210
  workdir, readonly=True, ev=ev,
2130
2211
  resume=sids.get(agent["id"]))
2131
2212
  cj = _critique_json(res, dims)
2132
2213
  if cj.get("scores"):
2133
2214
  scored += 1
2215
+ # 逐项目标审稿 + 资源账本:解析评审回复里的标记块(借鉴
2216
+ # AI-Novel-Writer 逐项核对/角色资源账本)。多评审取第一份
2217
+ # 非空即可,聚合时已在前两处赋值处去重。
2218
+ evl = _parse_tagged_lines(res.get("text"), "event_check")
2219
+ if evl and not ev_check_lines[0]:
2220
+ ev_check_lines[0] = evl
2221
+ ledger_lines.extend(_parse_tagged_lines(res.get("text"), "ledger"))
2134
2222
  # §07 T1.1:记录该评审的会话 id(第 2 轮复用)
2135
2223
  csid = _resume_sid(agent, res.get("sid"))
2136
2224
  if csid:
@@ -2150,7 +2238,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2150
2238
  res = _run_step(run_id, role, modelhub.bind_agent(spare, difficulty),
2151
2239
  crit_prompt_for(
2152
2240
  text,
2153
- note="小说第 %d 章" % i) + note_extra,
2241
+ note="小说第 %d 章" % i,
2242
+ event_check=event_check) + note_extra,
2154
2243
  workdir, readonly=True, ev=ev)
2155
2244
  cj = runner.extract_json(res.get("text") or "")
2156
2245
  if isinstance(cj, dict) and isinstance(cj.get("scores"), dict) \
@@ -2222,6 +2311,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2222
2311
  .replace("__GOAL__", task["goal"])
2223
2312
  .replace("__OUTLINE__", outline_txt)
2224
2313
  .replace("__PREV__", prev)
2314
+ .replace("__LEDGER__", ledger_txt)
2225
2315
  .replace("__TITLE__", ch["title"])
2226
2316
  .replace("__BEATS__", ch["beats"] or "按大纲推进")
2227
2317
  .replace("__HOOK__", ch.get("hook") or "留下悬念")
@@ -2474,7 +2564,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2474
2564
  if rnd == 1 and race_cj is not None:
2475
2565
  cj_by_agent, scored = race_cj, race_scored
2476
2566
  else:
2477
- cj_by_agent, scored, sids_now = run_critique(text, rnd, critic_sids=critic_sids)
2567
+ cj_by_agent, scored, sids_now = run_critique(
2568
+ text, rnd, critic_sids=critic_sids, event_check=event_block)
2478
2569
  critic_sids.update(sids_now)
2479
2570
  if not scored:
2480
2571
  # 「评不上」≠「评了 0 分」:全部评审失败时中止本轮,
@@ -2538,12 +2629,23 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2538
2629
  impl, implement_reason="章节修订:%s" %
2539
2630
  (impl.get("label") or impl.get("id")))
2540
2631
  _check_cancel(ev)
2541
- chapter_scores.append({"chapter": i, "title": ch["title"], "means": means,
2542
- "passed": bool(means) and all(v >= threshold_ch for v in means.values()),
2543
- "rounds": rounds_used,
2544
- "words": _wc(_read_chapter(workdir, i))})
2632
+ cs_new = {"chapter": i, "title": ch["title"], "means": means,
2633
+ "passed": bool(means) and all(v >= threshold_ch for v in means.values()),
2634
+ "rounds": rounds_used,
2635
+ "words": _wc(_read_chapter(workdir, i))}
2636
+ if ev_check_lines[0]:
2637
+ cs_new["event_check"] = ev_check_lines[0][:8] # 逐项目标审稿结果
2638
+ chapter_scores.append(cs_new)
2545
2639
  # 每章即时持久化:长篇中断/超时后可断点续跑,不丢已完成章的分数
2546
2640
  store.update_run(run_id, chapter_scores=chapter_scores)
2641
+ # 资源账本(借鉴角色资源账本):评审提出的道具/伤情/承诺/伏笔增量
2642
+ # 追加到 .codebee/resource-ledger.md,下一章起草时注入,防跨章穿帮。
2643
+ # 失败静默——账本是增强不是硬依赖。
2644
+ if ledger_lines:
2645
+ try:
2646
+ _append_ledger(workdir, i, ledger_lines)
2647
+ except Exception:
2648
+ pass
2547
2649
  # findings 沉淀(借鉴 agentmemory 持久记忆):章节标题+要点追加到
2548
2650
  # .codebee/findings.md——后续章节起草时随圣经/模块库注入,弥补
2549
2651
  # 前情提要只看近 2 章结尾的中期记忆空洞。失败静默。
@@ -0,0 +1,74 @@
1
+ # -*- coding: utf-8 -*-
2
+ """偏好记忆(借鉴 chinese-novelist-skill 的偏好记忆,2026-09-21):记住用户
3
+ 上次创建任务时选择的类型与参数,新建任务时预填「最近一次的选择」而不是
4
+ 写死的出厂默认——高频用户不用每次重调轮数/阈值/章节。
5
+
6
+ 只记参数不记内容(目标/标题绝不落库);单文件带锁读写,损坏即弃。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import threading
12
+
13
+ from . import paths
14
+
15
+ _LOCK = threading.Lock()
16
+
17
+ SAVE_KEYS = ("type", "rounds", "threshold", "best_of", "chapters",
18
+ "words_per_chapter", "variants")
19
+ _LIMITS = {"rounds": (1, 5), "threshold": (1.0, 10.0), "best_of": (1, 3),
20
+ "chapters": (1, 2000), "words_per_chapter": (200, 20000),
21
+ "variants": (1, 3)}
22
+
23
+
24
+ def _file():
25
+ # 动态取路径:DATA_DIR 可被测试重定向,模块级缓存会绑死首次导入的目录
26
+ return paths.DATA_DIR / "last-prefs.json"
27
+
28
+
29
+ def _clamp(key, value):
30
+ """数值钳制:历史文件被手改出天际值时按参数合法域兜底。"""
31
+ lo, hi = _LIMITS.get(key), (0, 10 ** 9)
32
+ lo, hi = lo if lo else hi
33
+ try:
34
+ return max(lo, min(hi, type(lo)(value) if isinstance(lo, int)
35
+ else float(value)))
36
+ except (TypeError, ValueError):
37
+ return None
38
+
39
+
40
+ def record(payload):
41
+ """从创建任务的 payload 提取可复用参数(存在才覆盖,单键独立保留)。"""
42
+ if not isinstance(payload, dict):
43
+ return
44
+ with _LOCK:
45
+ cur = load()
46
+ for key in SAVE_KEYS:
47
+ v = payload.get(key)
48
+ if v is None:
49
+ continue
50
+ if key == "type":
51
+ v = str(v)[:32]
52
+ if v:
53
+ cur["type"] = v
54
+ continue
55
+ c = _clamp(key, v)
56
+ if c is not None:
57
+ cur[key] = c
58
+ tmp = _file().with_suffix(".tmp")
59
+ try:
60
+ paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
61
+ tmp.write_text(json.dumps(cur, ensure_ascii=False, indent=2),
62
+ encoding="utf-8")
63
+ tmp.replace(_file())
64
+ except OSError:
65
+ pass
66
+
67
+
68
+ def load():
69
+ """读最近偏好;文件缺失/损坏返回 {}。"""
70
+ try:
71
+ data = json.loads(_file().read_text(encoding="utf-8"))
72
+ return data if isinstance(data, dict) else {}
73
+ except (OSError, ValueError):
74
+ return {}
@@ -397,15 +397,18 @@ def pretty_cli_log(text, max_event_chars=4000):
397
397
  return "\n".join(out)
398
398
 
399
399
 
400
- def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
401
- timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
402
- stall_timeout=0):
400
+ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
401
+ timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
402
+ stall_timeout=0, repeat_abort=None):
403
403
  """通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
404
404
 
405
405
  stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
406
406
  新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
407
- 持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
408
- 开了会把正常长任务误杀。
407
+ 持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
408
+ 开了会把正常长任务误杀。
409
+
410
+ repeat_abort:可选 (marker, count)。同一致命错误达到次数即提前杀树,处理
411
+ CLI 自身不断打印重连消息、因此永远触发不了静默看门狗的假运行。
409
412
 
410
413
  返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled}。
411
414
  """
@@ -468,7 +471,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
468
471
  pass
469
472
  threading.Thread(target=_feed, daemon=True).start()
470
473
  start = time.time()
471
- cancelled = timed_out = stalled = False
474
+ cancelled = timed_out = stalled = repeat_aborted = False
472
475
  while True:
473
476
  try:
474
477
  proc.wait(timeout=0.4)
@@ -485,14 +488,27 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
485
488
  _kill_tree(proc.pid)
486
489
  _drain_streams(proc, t_out, t_err, timeout=5)
487
490
  break
488
- if stall_timeout and time.time() - stamp[0] > stall_timeout:
491
+ if stall_timeout and time.time() - stamp[0] > stall_timeout:
489
492
  # 停滞看门狗:长静默多为卡死(网关挂起/CLI 假死),与其耗满总
490
493
  # 超时不如提前杀——错误按超时归类,走既有的换模型/换将链路
491
494
  stalled = True
492
495
  timed_out = True
493
496
  _kill_tree(proc.pid)
494
- _drain_streams(proc, t_out, t_err, timeout=5)
495
- break
497
+ _drain_streams(proc, t_out, t_err, timeout=5)
498
+ break
499
+ if repeat_abort:
500
+ try:
501
+ marker, limit = repeat_abort
502
+ recent = decode_output(b"".join(out_chunks)[-8000:] +
503
+ b"\n" + b"".join(err_chunks)[-8000:])
504
+ if marker and recent.count(str(marker)) >= int(limit):
505
+ repeat_aborted = True
506
+ timed_out = True
507
+ _kill_tree(proc.pid)
508
+ _drain_streams(proc, t_out, t_err, timeout=5)
509
+ break
510
+ except (TypeError, ValueError):
511
+ pass
496
512
  duration = round(time.time() - start, 1)
497
513
  t_out.join(timeout=5)
498
514
  t_err.join(timeout=5)
@@ -505,9 +521,11 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
505
521
  stream.close()
506
522
  except Exception:
507
523
  pass
508
- if cancelled:
509
- stderr += "\n[已被用户取消]"
510
- elif stalled:
524
+ if cancelled:
525
+ stderr += "\n[已被用户取消]"
526
+ elif repeat_aborted:
527
+ stderr += "\n[同一网络错误重复 %s 次,已提前终止进程树]" % repeat_abort[1]
528
+ elif stalled:
511
529
  stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
512
530
  elif timed_out:
513
531
  stderr += "\n[超时 %ss,已终止进程树]" % timeout
@@ -1050,10 +1068,12 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1050
1068
  att["model"], prompt,
1051
1069
  images=images if kind == "codex" else None,
1052
1070
  workdir=workdir)
1053
- try:
1054
- res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
1055
- timeout=timeout, cancel_event=cancel_event, log_path=log_path,
1056
- stall_timeout=stall_t)
1071
+ try:
1072
+ repeat_guard = (("Reconnecting... waiting for network", 5)
1073
+ if kind == "codex" else None)
1074
+ res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
1075
+ timeout=timeout, cancel_event=cancel_event, log_path=log_path,
1076
+ stall_timeout=stall_t, repeat_abort=repeat_guard)
1057
1077
  finally:
1058
1078
  # 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
1059
1079
  for tf in tmp_files:
@@ -1089,10 +1109,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1089
1109
  parsed_err = _parse_claude_json(res["stdout"] or "")
1090
1110
  if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
1091
1111
  out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
1092
- if kind == "codex" and att.get("provider_id"):
1093
- el = out["error"].lower()
1094
- if (("wire_api" in el and "no longer supported" in el)
1095
- or "no route matched" in el):
1112
+ if kind == "codex" and att.get("provider_id"):
1113
+ el = out["error"].lower()
1114
+ if (("wire_api" in el and "no longer supported" in el)
1115
+ or "no route matched" in el
1116
+ or "同一网络错误重复" in out["error"]):
1096
1117
  # codex 撞 chat-only 供应商(0.154 只讲 responses):
1097
1118
  # 自动冷却该供应商 30 分钟,链展开/路由绑定分随之
1098
1119
  # 自动绕开——不用人工改绑定(2026-09-17 mo-so 实测)
package/app/main.py CHANGED
@@ -308,6 +308,10 @@ class Handler(BaseHTTPRequestHandler):
308
308
  # 一键反馈 Issue 的预填摘要(标题+正文,全程脱敏,用户亲手提交)
309
309
  from core import telemetry
310
310
  return self._json(200, telemetry.issue_report(days=30))
311
+ if path == "/api/prefs":
312
+ # 偏好记忆:上次创建任务的类型/参数(新建表单预填用)
313
+ from core import prefs
314
+ return self._json(200, {"prefs": prefs.load()})
311
315
  if path == "/api/ports":
312
316
  # 端口占用诊断(借鉴 leftopen):谁在听、PID/进程/项目归属、
313
317
  # 是否仅本机。?port=N 只看单端口。只读,不碰任何进程。
@@ -2051,7 +2055,15 @@ class Handler(BaseHTTPRequestHandler):
2051
2055
  return 200, {"task_id": task["id"], "run_id": run["id"]}
2052
2056
 
2053
2057
  def _api_create_task(self):
2054
- status, resp = self._create_and_start(self._body())
2058
+ body = self._body()
2059
+ status, resp = self._create_and_start(body)
2060
+ if status == 200:
2061
+ # 偏好记忆(借鉴 chinese-novelist-skill):记住本次选择,下次新建预填
2062
+ try:
2063
+ from core import prefs
2064
+ prefs.record(body)
2065
+ except Exception:
2066
+ pass
2055
2067
  return self._json(status, resp)
2056
2068
 
2057
2069
  def _api_task_clarify(self):
package/app/pet.py CHANGED
@@ -359,6 +359,85 @@ def global_lock_held():
359
359
  return False
360
360
 
361
361
 
362
+ # 浏览器进程白名单:找已打开的 CodeBee 窗口时只认这些主进程的顶层窗口,
363
+ # 避免把桌宠自身(标题也是 CodeBee)或 start-public.bat 起的「CodeBee」
364
+ # 控制台窗口误当成浏览器置前。
365
+ _BROWSER_EXES = {
366
+ "msedge.exe", "chrome.exe", "chromium.exe", "firefox.exe",
367
+ "iexplore.exe", "opera.exe", "brave.exe", "vivaldi.exe",
368
+ "browser.exe", "waterfox.exe", "palemoon.exe", "seamonkey.exe",
369
+ "centbrowser.exe", "360chrome.exe", "qqbrowser.exe",
370
+ "sogouexplorer.exe", "maxthon.exe",
371
+ }
372
+
373
+
374
+ def _window_process_name(hwnd):
375
+ """取顶层窗口所属进程的可执行文件名(小写);失败返回空串。"""
376
+ try:
377
+ import ctypes
378
+ from ctypes import wintypes
379
+ user32 = ctypes.windll.user32
380
+ kernel32 = ctypes.windll.kernel32
381
+ pid = wintypes.DWORD()
382
+ user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
383
+ if not pid.value:
384
+ return ""
385
+ kernel32.OpenProcess.restype = wintypes.HANDLE
386
+ # PROCESS_QUERY_LIMITED_INFORMATION=0x1000:只需取映像路径
387
+ h = kernel32.OpenProcess(0x1000, False, pid.value)
388
+ if not h:
389
+ return ""
390
+ try:
391
+ buf = ctypes.create_unicode_buffer(1024)
392
+ size = wintypes.DWORD(1024)
393
+ if kernel32.QueryFullProcessImageNameW(h, 0, buf,
394
+ ctypes.byref(size)):
395
+ return Path(buf.value).name.lower()
396
+ finally:
397
+ kernel32.CloseHandle(h)
398
+ except Exception:
399
+ pass
400
+ return ""
401
+
402
+
403
+ def _focus_codebee_window(ignore_hwnd=None):
404
+ """Windows:已打开过 CodeBee 浏览器页时,把那个窗口找出来还原并置前,
405
+ 避免每点一次桌宠就新开一个浏览器窗口。返回 True 表示已找到并尝试置前;
406
+ False 表示没有现成窗口,调用方应回退 webbrowser.open 新开。"""
407
+ try:
408
+ import ctypes
409
+ from ctypes import wintypes
410
+ user32 = ctypes.windll.user32
411
+ EnumWindowsProc = ctypes.WINFUNCTYPE(
412
+ wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
413
+ found = []
414
+
415
+ def _cb(hwnd, _lp):
416
+ if hwnd == ignore_hwnd or not user32.IsWindowVisible(hwnd):
417
+ return True
418
+ if _window_process_name(hwnd) not in _BROWSER_EXES:
419
+ return True
420
+ length = user32.GetWindowTextLengthW(hwnd)
421
+ if length <= 0:
422
+ return True
423
+ buf = ctypes.create_unicode_buffer(length + 1)
424
+ user32.GetWindowTextW(hwnd, buf, length + 1)
425
+ if "CodeBee" in buf.value:
426
+ found.append(hwnd)
427
+ return True
428
+
429
+ user32.EnumWindows(EnumWindowsProc(_cb), 0)
430
+ if not found:
431
+ return False
432
+ target = found[0]
433
+ if user32.IsIconic(target):
434
+ user32.ShowWindow(target, 9) # SW_RESTORE:最小化先还原
435
+ user32.SetForegroundWindow(target)
436
+ return True
437
+ except Exception:
438
+ return False
439
+
440
+
362
441
  class PetApp:
363
442
  """蜜蜂窗口:蜜蜂本体(精灵/手绘)+ 状态机动画 + 悬停任务清单 + 右键菜单。"""
364
443
 
@@ -1296,7 +1375,12 @@ class PetApp:
1296
1375
 
1297
1376
  def _open_ui(self):
1298
1377
  try:
1299
- webbrowser.open("http://127.0.0.1:%d" % self.port)
1378
+ url = "http://127.0.0.1:%d" % self.port
1379
+ # 已开过就不新开:Windows 上先找现有 CodeBee 浏览器窗口置前,
1380
+ # 找不到(没开/标签不在前台)才回退 webbrowser.open 新开。
1381
+ if os.name == "nt" and _focus_codebee_window(self.root.winfo_id()):
1382
+ return
1383
+ webbrowser.open(url)
1300
1384
  except Exception:
1301
1385
  pass
1302
1386
 
package/app/ui/app.js CHANGED
@@ -2,10 +2,15 @@
2
2
  "use strict";
3
3
 
4
4
  const $ = (id) => document.getElementById(id);
5
- const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, creatingTask: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, _rdCtx: {} };
5
+ const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, creatingTask: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, _rdCtx: {}, lastPrefs: null };
6
6
 
7
7
  /* ---------------------------------------------------------- 任务类型(流程) */
8
8
  async function loadFlows() {
9
+ // 偏好记忆(借鉴 chinese-novelist-skill):并行拉最近一次的任务参数,
10
+ // renderTypeOptions/onTypeChange 用它预填类型与轮数/阈值等(拉失败不挡首屏)
11
+ api("/api/prefs").then((r) => { S.lastPrefs = (r && r.prefs) || {}; })
12
+ .then(() => renderTypeOptions())
13
+ .catch(() => {});
9
14
  try {
10
15
  const r = await api("/api/flows");
11
16
  S.flows = r.flows || [];
@@ -43,11 +48,12 @@ function flowDesc(f) {
43
48
  function renderTypeOptions() {
44
49
  const sel = $("f-type");
45
50
  if (!sel || !S.flows) return;
46
- const prev = sel.value;
47
- sel.innerHTML = (S.flows || []).map((f) => {
51
+ const prev = sel.value; sel.innerHTML = (S.flows || []).map((f) => {
48
52
  return '<option value="' + esc(f.id) + '">' + esc(t(f.name)) + t("(") + flowDesc(f) + (f.builtin ? "" : t(" · 自定义")) + t(")") + "</option>";
49
53
  }).join("");
50
54
  if (prev && flowById(prev)) sel.value = prev;
55
+ // 偏好记忆:上次创建用的类型优先于出厂默认「直接执行」(老数据/已删流程回落)
56
+ else if (S.lastPrefs && S.lastPrefs.type && flowById(S.lastPrefs.type)) sel.value = S.lastPrefs.type;
51
57
  // 首屏默认落在「直接执行」(快档位:单 CLI 直达,无拆解/评审);
52
58
  // 老数据或该流程被删时保持首项,不硬造一个不存在的值
53
59
  else if (flowById("direct")) sel.value = "direct";
@@ -160,9 +166,11 @@ function onTypeChange() {
160
166
  if (goal && flow && flow.goal_hint) goal.placeholder = t(flow.goal_hint);
161
167
  if (isReview && flow) {
162
168
  if (flow.manuscript) $("f-manuscript").value = flow.manuscript;
163
- if (flow.rounds) $("f-rounds").value = flow.rounds;
164
- if (flow.threshold) $("f-threshold").value = flow.threshold;
165
- if (flow.best_of) $("f-bestof").value = flow.best_of;
169
+ // 偏好记忆(借鉴 chinese-novelist-skill):上次创建用过的参数优先于流程出厂默认
170
+ const lp = S.lastPrefs || {};
171
+ $("f-rounds").value = lp.rounds || flow.rounds || 2;
172
+ $("f-threshold").value = (lp.threshold != null ? lp.threshold : (flow.threshold || 7.0));
173
+ $("f-bestof").value = lp.best_of || flow.best_of || 1;
166
174
  const rubric = $("f-rubric");
167
175
  const previousFlow = rubric.dataset.flowId || "";
168
176
  if (!S.flowRubricDrafts) S.flowRubricDrafts = {};
@@ -175,10 +183,10 @@ function onTypeChange() {
175
183
  : (flow.rubric || []).join(", ");
176
184
  rubric.dataset.flowId = flow.id;
177
185
  }
178
- // 连载参数预填(用户可改/可清空 = 单稿件模式)
186
+ // 连载参数预填(用户可改/可清空 = 单稿件模式);偏好记忆的最近值优先
179
187
  if (flow.serial) {
180
- if (!$("f-chapters").value) $("f-chapters").value = flow.serial.chapters || "";
181
- if (!$("f-words-per-ch").value) $("f-words-per-ch").value = flow.serial.words_per_chapter || "";
188
+ if (!$("f-chapters").value) $("f-chapters").value = lp.chapters || flow.serial.chapters || "";
189
+ if (!$("f-words-per-ch").value) $("f-words-per-ch").value = lp.words_per_chapter || flow.serial.words_per_chapter || "";
182
190
  } else clearSerialFields();
183
191
  } else {
184
192
  // 隐藏控件仍保留 DOM 值;换流程时清理,避免章节/圣经状态污染下一次提交。
@@ -301,6 +309,125 @@ let _requestBusyCount = 0;
301
309
  let _lastActionButton = null;
302
310
  let _lastActionAt = 0;
303
311
  const _requestBusyButtons = new WeakMap();
312
+ const _operations = [];
313
+ let _operationSeq = 0;
314
+
315
+ function operationLabel(path, opts) {
316
+ if (opts && opts.operation === false) return "";
317
+ if (opts && typeof opts.operation === "string") return t(opts.operation);
318
+ if (opts && opts.busy === false && opts.operation !== true) return "";
319
+ const method = String((opts && opts.method) || "GET").toUpperCase();
320
+ if (method === "GET" || method === "HEAD") return "";
321
+ // 只自动记录刚由用户点击触发的写操作;后台维护请求需显式传 operation 才显示。
322
+ if (!(opts && opts.operation === true) &&
323
+ (!_lastActionAt || performance.now() - _lastActionAt >= 2000)) return "";
324
+ const clean = String(path || "").split("?")[0];
325
+ const rules = [
326
+ [/^\/api\/tasks$/, "创建任务"],
327
+ [/\/tasks\/[^/]+\/delete$/, "删除任务"],
328
+ [/\/tasks\/[^/]+\/archive$/, "归档任务"],
329
+ [/\/tasks\/[^/]+\/retry$/, "重试任务"],
330
+ [/\/tasks\/[^/]+\/continue$/, "创建续写任务"],
331
+ [/\/tasks\/[^/]+\/rename$/, "重命名任务"],
332
+ [/\/runs\/[^/]+\/cancel$/, "取消运行"],
333
+ [/\/runs\/[^/]+\/delete$/, "删除运行"],
334
+ [/\/runs\/(delete-batch|clear)$/, "清理运行记录"],
335
+ [/\/attachments(?:\/[^/]+)?$/, "上传附件"],
336
+ [/\/selfupdate\/apply$/, "升级 CodeBee"],
337
+ [/\/market\//, "更新插件"],
338
+ [/\/models\/binding$/, "更新智能体绑定"],
339
+ [/\/models\//, "更新模型配置"],
340
+ ];
341
+ const hit = rules.find((it) => it[0].test(clean));
342
+ return t(hit ? hit[1] : "提交操作");
343
+ }
344
+
345
+ function operationStart(path, opts) {
346
+ const label = operationLabel(path, opts);
347
+ if (!label) return null;
348
+ const op = { id: ++_operationSeq, label, status: "running", at: Date.now(), error: "" };
349
+ _operations.unshift(op);
350
+ if (_operations.length > 12) _operations.length = 12;
351
+ renderOperationCenter();
352
+ return op.id;
353
+ }
354
+
355
+ function operationFinish(id, status, error) {
356
+ if (!id) return;
357
+ const op = _operations.find((it) => it.id === id);
358
+ if (!op) return;
359
+ op.status = status;
360
+ op.error = String(error || "").slice(0, 240);
361
+ op.endedAt = Date.now();
362
+ renderOperationCenter();
363
+ }
364
+
365
+ function operationTime(ts) {
366
+ try { return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
367
+ catch (e) { return ""; }
368
+ }
369
+
370
+ function renderOperationCenter() {
371
+ const button = $("btn-ops");
372
+ const list = $("op-list");
373
+ if (!button || !list) return;
374
+ button.classList.toggle("hidden", !_operations.length);
375
+ const running = _operations.filter((it) => it.status === "running").length;
376
+ const failed = _operations.filter((it) => it.status === "failed").length;
377
+ button.classList.toggle("running", running > 0);
378
+ button.classList.toggle("failed", !running && failed > 0);
379
+ button.classList.toggle("done", !running && !failed && _operations.length > 0);
380
+ const pill = $("op-pill-text");
381
+ if (pill) pill.textContent = running ? t("{0} 项处理中").replace("{0}", running)
382
+ : failed ? t("{0} 项失败").replace("{0}", failed) : t("操作完成");
383
+ list.innerHTML = _operations.map((op) => {
384
+ const status = op.status === "running" ? t("处理中")
385
+ : op.status === "failed" ? t("失败") : t("已完成");
386
+ const remove = op.status === "running" ? "" :
387
+ '<button type="button" class="op-dismiss" aria-label="' + esc(t("移除")) +
388
+ '" title="' + esc(t("移除")) + '" onclick="dismissOperation(event,' + op.id + ')">' +
389
+ '<svg class="ico" aria-hidden="true"><use href="#i-x"></use></svg></button>';
390
+ return '<div class="op-row ' + op.status + '"><span class="op-state" aria-hidden="true"></span>' +
391
+ '<div class="op-copy"><div><b>' + esc(op.label) + '</b><span>' + esc(status) + " · " +
392
+ esc(operationTime(op.endedAt || op.at)) + '</span></div>' +
393
+ (op.error ? '<p title="' + esc(op.error) + '">' + esc(op.error) + "</p>" : "") +
394
+ "</div>" + remove + "</div>";
395
+ }).join("");
396
+ }
397
+
398
+ function toggleOperationCenter(force) {
399
+ const panel = $("op-center");
400
+ const button = $("btn-ops");
401
+ if (!panel || !button) return;
402
+ const open = force === undefined ? panel.classList.contains("hidden") : !!force;
403
+ panel.classList.toggle("hidden", !open);
404
+ button.setAttribute("aria-expanded", open ? "true" : "false");
405
+ }
406
+
407
+ function clearFinishedOperations() {
408
+ for (let i = _operations.length - 1; i >= 0; i--) {
409
+ if (_operations[i].status === "done") _operations.splice(i, 1);
410
+ }
411
+ renderOperationCenter();
412
+ if (!_operations.length) toggleOperationCenter(false);
413
+ }
414
+
415
+ function dismissOperation(event, id) {
416
+ if (event) event.stopPropagation();
417
+ const i = _operations.findIndex((it) => it.id === id && it.status !== "running");
418
+ if (i >= 0) _operations.splice(i, 1);
419
+ renderOperationCenter();
420
+ if (!_operations.length) toggleOperationCenter(false);
421
+ }
422
+
423
+ window.toggleOperationCenter = toggleOperationCenter;
424
+ window.clearFinishedOperations = clearFinishedOperations;
425
+ window.dismissOperation = dismissOperation;
426
+
427
+ document.addEventListener("click", (e) => {
428
+ const wrap = $("op-wrap");
429
+ if (wrap && !wrap.contains(e.target)) toggleOperationCenter(false);
430
+ });
304
431
 
305
432
  /* 记录发起请求的按钮。写请求统一在 api() 里挂忙碌态,避免每个业务入口各写一套;
306
433
  * 确认框的按钮不覆盖原始操作按钮,但会续期,使“确认后执行”仍反馈在原按钮上。 */
@@ -394,10 +521,12 @@ async function api(path, opts) {
394
521
  var timer = setTimeout(() => ctrl.abort(), opts.timeout);
395
522
  }
396
523
  const busyToken = requestBusyStart(opts);
524
+ const operationToken = operationStart(path, opts);
397
525
  const fetchOpts = Object.assign({}, opts);
398
526
  delete fetchOpts.timeout;
399
527
  delete fetchOpts.busy;
400
528
  delete fetchOpts.busyElement;
529
+ delete fetchOpts.operation;
401
530
  try {
402
531
  let res;
403
532
  try {
@@ -412,7 +541,11 @@ async function api(path, opts) {
412
541
  try { data = await res.json(); } catch (e) { /* ignore */ }
413
542
  if (res.status === 423 && data && data.control) setControl(data.control);
414
543
  if (!res.ok) throw new Error((data && data.error) || ("HTTP " + res.status));
544
+ operationFinish(operationToken, "done");
415
545
  return data;
546
+ } catch (e) {
547
+ operationFinish(operationToken, "failed", e && e.message ? e.message : e);
548
+ throw e;
416
549
  } finally {
417
550
  if (timer) clearTimeout(timer);
418
551
  requestBusyEnd(busyToken);
@@ -2027,7 +2160,7 @@ async function createTask() {
2027
2160
  try {
2028
2161
  const cq = await api("/api/tasks/clarify", {
2029
2162
  // 澄清只是增强,不能让建任务被模型调用拖住;超时后直接按原目标开跑。
2030
- method: "POST", timeout: 8000,
2163
+ method: "POST", timeout: 8000, operation: false,
2031
2164
  body: JSON.stringify({ goal: payload.goal, type: payload.type }) });
2032
2165
  const qs = (cq && cq.questions) || [];
2033
2166
  if (qs.length) {
@@ -2089,7 +2222,7 @@ async function createTask() {
2089
2222
  if (critics.length) payload.critics = critics;
2090
2223
  }
2091
2224
  try {
2092
- const r = await api("/api/tasks", { method: "POST", timeout: 30000,
2225
+ const r = await api("/api/tasks", { method: "POST", timeout: 30000, operation: "创建任务",
2093
2226
  body: JSON.stringify(payload) });
2094
2227
  msg.textContent = t("已创建,跳转运行页…");
2095
2228
  // 先把新任务刷进 state 再跳:chatEngineIsDirect 靠 S.state.tasks 判引擎,
@@ -2383,7 +2516,8 @@ async function deleteTask(id) {
2383
2516
  let gone = false;
2384
2517
  try {
2385
2518
  toast(t("正在删除任务…"));
2386
- await api("/api/tasks/" + encodeURIComponent(id) + "/delete", { method: "POST" });
2519
+ await api("/api/tasks/" + encodeURIComponent(id) + "/delete",
2520
+ { method: "POST", operation: "删除任务" });
2387
2521
  } catch (e) {
2388
2522
  // 任务已不在(他端删过 / 重复点):不报错晾着,照常把视图清掉
2389
2523
  if (!/任务不存在/.test(e.message)) { toast(t("删除失败:") + e.message, true); return; }
@@ -8344,7 +8478,7 @@ function renderZentaoProfiles() {
8344
8478
  const box = $("zt-profiles");
8345
8479
  if (!box) return;
8346
8480
  box.innerHTML = (S.ztProfiles || []).map((p, i) => ztProfCardHtml(p, i)).join("") ||
8347
- '<div class="empty">' + t("还没有产品档案——点「+ 添加产品」,填产品 ID 与两端仓库。") + "</div>";
8481
+ '<div class="empty">' + t("还没有产品档案——先测试连接,再从禅道选择产品,无需自己查 ID。") + "</div>";
8348
8482
  }
8349
8483
 
8350
8484
  /* DOM 卡片 → profiles 数组(结构变化前采集,保住已输入值) */
@@ -8385,6 +8519,10 @@ function ztProfAdd() {
8385
8519
  repos: { backend: {}, frontend: {} }, repo_hints: { backend: "", frontend: "" },
8386
8520
  owners: { backend: "", frontend: "", not_ours: "" }, module_routes: [] });
8387
8521
  renderZentaoProfiles();
8522
+ // 首次添加时自动拉清单,避免用户只能盲填产品 ID;拉不到仍保留手填回退。
8523
+ if (!(S.ztProducts || []).length && $("zt-base-url") && $("zt-base-url").value.trim()) {
8524
+ ztFetchCatalog(false);
8525
+ }
8388
8526
  }
8389
8527
 
8390
8528
  function ztProfDel(i) {
@@ -8515,7 +8653,7 @@ function zentaoOpenRun(runId) {
8515
8653
  }
8516
8654
 
8517
8655
  /* 表单 → 保存配置。password 只在用户输入了才提交(空 = 保持已存密码)。 */
8518
- async function saveZentao() {
8656
+ function ztFormPayload() {
8519
8657
  const val = (id) => (($(id).value || "").trim());
8520
8658
  const payload = {
8521
8659
  base_url: val("zt-base-url"),
@@ -8529,6 +8667,11 @@ async function saveZentao() {
8529
8667
  };
8530
8668
  const pw = ($("zt-password").value || "").trim();
8531
8669
  if (pw) payload.password = pw;
8670
+ return payload;
8671
+ }
8672
+
8673
+ async function saveZentao() {
8674
+ const payload = ztFormPayload();
8532
8675
  try {
8533
8676
  await api("/api/zentao/config", { method: "POST", body: JSON.stringify(payload) });
8534
8677
  } catch (e) { toast(t("保存失败:") + e.message, true); return; }
@@ -8554,6 +8697,16 @@ async function testZentao() {
8554
8697
  toast(t("地址已自动补全子路径,记得「保存配置」"));
8555
8698
  }
8556
8699
  }
8700
+ if (r.ok) {
8701
+ try {
8702
+ // 测试成功即保存当前连接并拉产品清单,用户无需再理解“先保存再拉取”的顺序。
8703
+ await api("/api/zentao/config", { method: "POST", operation: false,
8704
+ body: JSON.stringify(ztFormPayload()) });
8705
+ await loadZentao();
8706
+ await ztFetchCatalog(true);
8707
+ toast(t("连接成功,已保存;现在可直接从禅道选择产品"));
8708
+ } catch (e) { toast(t("连接成功,但保存配置失败:") + e.message, true); }
8709
+ }
8557
8710
  }
8558
8711
 
8559
8712
  async function scanZentao() {
package/app/ui/i18n.js CHANGED
@@ -1276,6 +1276,29 @@
1276
1276
  "正在下载安装,插件包较大时需要约一分钟…": "Downloading and installing — large plugin packages can take about a minute…",
1277
1277
  "请求超时,请重试或检查网络": "Request timed out — try again or check your network",
1278
1278
  "操作处理中": "Working on it",
1279
+ "操作状态": "Operations",
1280
+ "清除已完成": "Clear completed",
1281
+ "创建任务": "Create task",
1282
+ "删除任务": "Delete task",
1283
+ "归档任务": "Archive task",
1284
+ "重试任务": "Retry task",
1285
+ "创建续写任务": "Create continuation",
1286
+ "重命名任务": "Rename task",
1287
+ "取消运行": "Cancel run",
1288
+ "删除运行": "Delete run",
1289
+ "清理运行记录": "Clear run history",
1290
+ "上传附件": "Upload attachment",
1291
+ "升级 CodeBee": "Upgrade CodeBee",
1292
+ "更新插件": "Update plugin",
1293
+ "更新模型配置": "Update model settings",
1294
+ "更新智能体绑定": "Update agent binding",
1295
+ "提交操作": "Submit operation",
1296
+ "{0} 项处理中": "{0} in progress",
1297
+ "{0} 项失败": "{0} failed",
1298
+ "操作完成": "Operation complete",
1299
+ "处理中": "In progress",
1300
+ "失败": "Failed",
1301
+ "已完成": "Completed",
1279
1302
  "该插件含脚本/钩子/MCP 组件,仅支持纯技能类插件": "Contains scripts/hooks/MCP components — only pure skill plugins are supported",
1280
1303
  " 个供应商(google 等协议)仅登记,不支持注入 CLI,未出现在上面的下拉中。": " provider(s) (google etc.) are registry-only, can't inject into CLIs, and are hidden from the dropdown above.",
1281
1304
  " 个降级备选)。": " fallback(s)).",
@@ -1939,6 +1962,10 @@
1939
1962
  "AI 排查兜底": "AI triage fallback",
1940
1963
  "产品档案": "Product Profiles",
1941
1964
  "+ 添加产品": "+ Add product",
1965
+ "+ 从禅道添加产品": "+ Add from ZenTao",
1966
+ "刷新产品列表": "Refresh products",
1967
+ "重新从禅道拉取产品与账号": "Reload products and accounts from ZenTao",
1968
+ "先测试连接,再点「从禅道添加产品」直接选择产品;无需自己查 ID。每个产品只需配置我方仓库,负责人和模块路由均可稍后补充。": "Test the connection, then choose Add from ZenTao — no ID lookup needed. Configure only your repository first; owners and module routes can be added later.",
1942
1969
  "删除产品": "Remove product",
1943
1970
  "每个产品一份档案:我方端(自动修)、两端仓库与负责人、模块路由(模块 → 端/人,排查优先级最高)。": "One profile per product: our sides (auto-fix), frontend/backend repos and owners, module routes (module → side/owner, highest triage priority).",
1944
1971
  "定时扫描禅道多个产品的激活 Bug:先按模块路由/AI 排查定责(前端/后端/双端/非我方),我方端自动创建代码修复任务(实现→验证→评审→修复),修完自动合并并 resolve 或转派对应负责人;测试指错人也按排查结论改派。": "Periodically scans active bugs across products: triages each (frontend/backend/both/not-ours) via module routes or AI, auto-fixes on our sides (implement → verify → review → fix), then merges & resolves or transfers to the right owner — including re-routing bugs the tester assigned to the wrong person.",
@@ -1976,6 +2003,7 @@
1976
2003
  "需人工": "Needs human",
1977
2004
  "还没有认领过 Bug——配置好连接与产品档案后点「立即扫描」。": "No bugs claimed yet — set up connection and product profiles, then click \"Scan Now\".",
1978
2005
  "还没有产品档案——点「+ 添加产品」,填产品 ID 与两端仓库。": "No product profiles yet — click \"+ Add product\" and fill the product ID and repos.",
2006
+ "还没有产品档案——先测试连接,再从禅道选择产品,无需自己查 ID。": "No product profiles yet — test the connection, then select a product from ZenTao; no ID lookup needed.",
1979
2007
  "先填产品 ID 再拉模块清单": "Fill the product ID before fetching modules",
1980
2008
  "先选产品再拉模块清单": "Pick a product before fetching modules",
1981
2009
  "拉取失败": "Fetch failed",
package/app/ui/index.html CHANGED
@@ -214,6 +214,15 @@
214
214
  <div id="health-banner" class="health-banner hidden" onclick="healthBannerClick()" title="供应商健康告警" aria-label="供应商健康告警" data-i18n-title="供应商健康告警" data-i18n-aria="供应商健康告警"></div>
215
215
  <div class="top-actions">
216
216
  <button id="ctrl-pill" class="pill ctrl icon-only hidden" onclick="ctrlClick()" title="多端控制权" aria-label="多端控制权" data-i18n-aria="多端控制权" data-i18n-title="多端控制权"><svg class="ico" aria-hidden="true"><use href="#i-unlock"></use></svg></button>
217
+ <div id="op-wrap" class="op-wrap">
218
+ <button id="btn-ops" class="pill op-pill hidden" type="button" onclick="toggleOperationCenter()" title="操作状态" aria-label="操作状态" aria-haspopup="dialog" aria-expanded="false" aria-controls="op-center" data-i18n-title="操作状态" data-i18n-aria="操作状态">
219
+ <svg class="ico" aria-hidden="true"><use href="#i-history"></use></svg><span id="op-pill-text"></span>
220
+ </button>
221
+ <div id="op-center" class="op-center hidden" role="dialog" aria-label="操作状态" data-i18n-aria="操作状态">
222
+ <div class="op-center-head"><b data-i18n="操作状态">操作状态</b><button type="button" class="ghost small" onclick="clearFinishedOperations()" data-i18n="清除已完成">清除已完成</button></div>
223
+ <div id="op-list" class="op-list" aria-live="polite"></div>
224
+ </div>
225
+ </div>
217
226
  <!-- 语言快捷切换:语言名用母语写死(中文/English),刻意不做 i18n——任何母语的人都要能读懂并选中 -->
218
227
  <div class="lang-wrap">
219
228
  <button id="btn-lang" class="pill icon-only" type="button" onclick="toggleLangMenu()" title="语言 / Language" aria-label="切换语言" aria-haspopup="listbox" aria-controls="lang-menu" data-i18n-title="语言 / Language" data-i18n-aria="切换语言"><svg class="ico" aria-hidden="true"><use href="#i-globe"></use></svg></button>
@@ -535,7 +544,7 @@
535
544
  <h2 data-i18n="禅道 Bug 自动修复">禅道 Bug 自动修复</h2>
536
545
  <button class="hhelp" data-help-topic="auto" title="这是什么?点开看帮助" data-i18n-title="这是什么?点开看帮助" aria-label="帮助" data-i18n-aria="帮助"><svg class="ico" aria-hidden="true"><use href="#i-help"></use></svg></button>
537
546
  <div class="actions">
538
- <button id="btn-zentao-test" class="ghost small" title="用当前表单里的连接信息试连禅道" data-i18n-title="用当前表单里的连接信息试连禅道"><span data-i18n="测试连接">测试连接</span></button>
547
+ <button id="btn-zentao-test" class="ghost small" title="测试成功后自动保存连接并拉取产品列表" data-i18n-title="测试成功后自动保存连接并拉取产品列表"><span data-i18n="测试并保存连接">测试并保存连接</span></button>
539
548
  <button id="btn-zentao-scan" class="primary small" title="立刻扫一遍激活 Bug 并对账回写" data-i18n-title="立刻扫一遍激活 Bug 并对账回写"><span data-i18n="立即扫描">立即扫描</span></button>
540
549
  </div>
541
550
  </div>
@@ -557,11 +566,11 @@
557
566
  </div>
558
567
  <div class="ops"><button id="btn-zentao-save" class="primary small"><span data-i18n="保存配置">保存配置</span></button></div>
559
568
  <h3 data-i18n="产品档案" class="sec-title">产品档案</h3>
560
- <p data-i18n="每个产品一份档案:我方端(自动修)、两端仓库与负责人、模块路由(模块 → 端/人,排查优先级最高)。" class="hint">每个产品一份档案:我方端(自动修)、两端仓库与负责人、模块路由(模块 → 端/人,排查优先级最高)。</p>
569
+ <p data-i18n="先测试连接,再点「从禅道添加产品」直接选择产品;无需自己查 ID。每个产品只需配置我方仓库,负责人和模块路由均可稍后补充。" class="hint">先测试连接,再点「从禅道添加产品」直接选择产品;无需自己查 ID。每个产品只需配置我方仓库,负责人和模块路由均可稍后补充。</p>
561
570
  <div id="zt-profiles" class="cards"></div>
562
571
  <div class="ops">
563
- <button id="btn-zentao-fetchcat" class="ghost small" title="从禅道拉产品与账号清单:产品 ID、负责人变下拉选择(需连接可用)" data-i18n-title="从禅道拉产品与账号清单:产品 ID、负责人变下拉选择(需连接可用)"><span data-i18n="拉取清单">拉取清单</span></button>
564
- <button id="btn-zentao-addprofile" class="ghost small"><span data-i18n="+ 添加产品">+ 添加产品</span></button>
572
+ <button id="btn-zentao-fetchcat" class="ghost small" title="重新从禅道拉取产品与账号" data-i18n-title="重新从禅道拉取产品与账号"><span data-i18n="刷新产品列表">刷新产品列表</span></button>
573
+ <button id="btn-zentao-addprofile" class="primary small"><span data-i18n="+ 从禅道添加产品">+ 从禅道添加产品</span></button>
565
574
  </div>
566
575
  <datalist id="zt-users"></datalist>
567
576
  <h3 data-i18n="修复记录" class="sec-title">修复记录</h3>
package/app/ui/style.css CHANGED
@@ -5456,3 +5456,55 @@ button.request-busy > * { opacity: .18; }
5456
5456
  .rd-route-row { grid-template-columns: 38px minmax(72px, 1fr); }
5457
5457
  .rd-route-reason { grid-column: 2; }
5458
5458
  }
5459
+
5460
+ /* ---------------- 统一操作状态中心(所有用户触发的写请求) ---------------- */
5461
+ .op-wrap { position: relative; }
5462
+ .op-pill { min-width: 118px; justify-content: center; }
5463
+ .op-pill.running { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
5464
+ .op-pill.failed { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 55%, var(--border)); }
5465
+ .op-pill.done { color: var(--ok); }
5466
+ .op-pill.running .ico { animation: op-center-spin 1.1s linear infinite; }
5467
+ @keyframes op-center-spin { to { transform: rotate(-360deg); } }
5468
+ .op-center {
5469
+ position: absolute; z-index: 140; top: calc(100% + 9px); right: 0;
5470
+ width: min(370px, calc(100vw - 24px)); max-height: min(480px, calc(100vh - 88px));
5471
+ overflow: hidden; border: 1px solid var(--border-strong); border-radius: 8px;
5472
+ background: var(--panel); box-shadow: var(--shadow-lg);
5473
+ }
5474
+ .op-center.hidden { display: none; }
5475
+ .op-center-head {
5476
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
5477
+ min-height: 48px; padding: 8px 10px 8px 14px; border-bottom: 1px solid var(--border);
5478
+ }
5479
+ .op-center-head b { font-size: 13px; }
5480
+ .op-list { max-height: min(430px, calc(100vh - 140px)); overflow: auto; }
5481
+ .op-row {
5482
+ display: grid; grid-template-columns: 10px minmax(0, 1fr) 28px; gap: 9px;
5483
+ align-items: start; min-height: 52px; padding: 10px 9px 10px 14px;
5484
+ border-bottom: 1px solid var(--border);
5485
+ }
5486
+ .op-row:last-child { border-bottom: 0; }
5487
+ .op-state { width: 8px; height: 8px; margin-top: 6px; border-radius: 50%; background: var(--ok); }
5488
+ .op-row.running .op-state { background: var(--accent); animation: pulse 1.1s ease-in-out infinite; }
5489
+ .op-row.failed .op-state { background: var(--bad); }
5490
+ .op-copy { min-width: 0; }
5491
+ .op-copy > div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
5492
+ .op-copy b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; }
5493
+ .op-copy span { flex: none; color: var(--muted); font-size: 10.5px; }
5494
+ .op-copy p {
5495
+ margin: 4px 0 0; color: var(--bad); font-size: 11px; line-height: 1.4;
5496
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
5497
+ }
5498
+ .op-dismiss {
5499
+ width: 26px; height: 26px; padding: 5px; border: 0; border-radius: 6px;
5500
+ color: var(--muted); background: transparent; cursor: pointer;
5501
+ }
5502
+ .op-dismiss:hover { color: var(--text); background: var(--panel2); }
5503
+ @media (max-width: 720px) {
5504
+ .op-pill { min-width: 34px; width: 34px; height: 32px; padding: 5px; }
5505
+ .op-pill #op-pill-text { display: none; }
5506
+ .op-center { position: fixed; top: 58px; right: 8px; width: calc(100vw - 16px); }
5507
+ }
5508
+ @media (prefers-reduced-motion: reduce) {
5509
+ .op-pill.running .ico, .op-row.running .op-state { animation: none; }
5510
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {