codebee 0.1.39 → 0.1.40

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,17 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
6
6
 
7
7
  ## 未发布
8
8
 
9
+ ## v0.1.40(2026-09-21)
10
+
11
+ ### 🛠 问题修复
12
+
13
+ - 「厂商/模型」选择器两步手选修复:点厂商只展开该厂商模型列表(不再自动带推荐,模型必须亲手点);修复点厂商后菜单瞬间被收起、模型永远选不上的问题(真机定位:菜单重写 DOM 与「点外面收起」监听的竞态);菜单改为向上弹出,不再被右侧栏遮挡。
14
+
15
+ ### ✨ 新功能
16
+
17
+ - 对话输入条与新建任务同款三件套:编排模式 / 思考程度 / 厂商模型选择器直接嵌在输入条工具行,改动即写入任务参数(下一轮对话生效);打开任务时自动回填当前值。
18
+ - 对话输入条的待发图片以缩略图展示(不再只是一串文件名),移除按钮悬浮右上角;气泡内图片缩略图不再压住输入框边框。
19
+
9
20
  ## v0.1.39(2026-09-21)
10
21
 
11
22
  ### 🛠 问题修复
package/app/core/store.py CHANGED
@@ -104,12 +104,12 @@ def create_task(payload):
104
104
  if not wd.is_dir():
105
105
  raise ValueError("工作目录不存在: %s" % workdir)
106
106
  mode = payload.get("mode")
107
- if mode not in ("auto", "fast", "expert", "manual"):
108
- mode = "manual" if payload.get("implementer") else "auto"
107
+ if mode not in ("auto", "fast", "expert", "manual"):
108
+ mode = "manual" if payload.get("implementer") else "auto"
109
109
  difficulty = payload.get("difficulty")
110
110
  if difficulty not in ("auto", "easy", "hard", "default"):
111
111
  difficulty = "auto"
112
- task = {
112
+ task = {
113
113
  "id": _new_id("t"), "type": flow["id"], "engine": flow["engine"],
114
114
  "title": title, "goal": goal,
115
115
  "context": _text(payload.get("context"), "context"),
@@ -119,16 +119,16 @@ def create_task(payload):
119
119
  "implementer": payload.get("implementer") or "",
120
120
  "attachments": [],
121
121
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
122
- "status": "created",
123
- }
124
- thinking = str(payload.get("thinking") or "standard").strip().lower()
125
- task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
126
- if flow["engine"] == "direct":
127
- task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
128
- "direct_provider_id")[:80]
129
- task["direct_model"] = _text(payload.get("direct_model"), "direct_model")[:160]
130
- if task["direct_model"] and not task["direct_provider_id"]:
131
- raise ValueError("指定对话模型时必须同时指定厂商")
122
+ "status": "created",
123
+ }
124
+ thinking = str(payload.get("thinking") or "standard").strip().lower()
125
+ task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
126
+ if flow["engine"] == "direct":
127
+ task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
128
+ "direct_provider_id")[:80]
129
+ task["direct_model"] = _text(payload.get("direct_model"), "direct_model")[:160]
130
+ if task["direct_model"] and not task["direct_provider_id"]:
131
+ raise ValueError("指定对话模型时必须同时指定厂商")
132
132
  # 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
133
133
  from . import gitmod
134
134
  git_rev = _text(payload.get("git_rev"), "git_rev")
@@ -141,8 +141,8 @@ def create_task(payload):
141
141
  task["git_rev"] = git_rev
142
142
  if flow["engine"] == "code":
143
143
  task["verify_command"] = _text(payload.get("verify_command"), "verify_command")
144
- elif flow["engine"] == "direct":
145
- pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
144
+ elif flow["engine"] == "direct":
145
+ pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
146
146
  else:
147
147
  ms = _text(payload.get("manuscript") or flow.get("manuscript") or "manuscript.md",
148
148
  "manuscript")
@@ -254,8 +254,8 @@ def create_task(payload):
254
254
  raise ValueError("附件落盘失败: %s" % e)
255
255
  if items:
256
256
  task["attachments"] = items
257
- task["context"] = att_mod.merge_context(
258
- task["context"], items, workdir=str(wd))
257
+ task["context"] = att_mod.merge_context(
258
+ task["context"], items, workdir=str(wd))
259
259
  with LOCK:
260
260
  _TASKS[task["id"]] = task
261
261
  _save_json(paths.TASKS_DIR / (task["id"] + ".json"), task)
@@ -454,16 +454,16 @@ def load_all():
454
454
  # 错误串(`[91m[1mError:`、U+FFFD 乱码墙),读盘时统一洗一遍——
455
455
  # 老运行不必等重跑才干净(2026-09-20「咋还有乱码」实测)
456
456
  _sanitize_run_text(r)
457
- # 直接执行线程不跨进程:running 与无截止时间的 queued 是上次
458
- # 进程中断残骸。带 resume_enqueue_at 的 queued 是有意的自动续跑
459
- # 退避,保留给 jobs.restore_deferred_resumes 重建 Timer。
460
- interrupted = (r.get("status") == "running" or
461
- (r.get("status") == "queued" and
462
- not r.get("resume_enqueue_at")))
463
- if interrupted:
464
- r["status"] = "failed"
465
- r["error"] = r.get("error") or "服务重启中断,可重试"
466
- _save_json(p, r)
457
+ # 直接执行线程不跨进程:running 与无截止时间的 queued 是上次
458
+ # 进程中断残骸。带 resume_enqueue_at 的 queued 是有意的自动续跑
459
+ # 退避,保留给 jobs.restore_deferred_resumes 重建 Timer。
460
+ interrupted = (r.get("status") == "running" or
461
+ (r.get("status") == "queued" and
462
+ not r.get("resume_enqueue_at")))
463
+ if interrupted:
464
+ r["status"] = "failed"
465
+ r["error"] = r.get("error") or "服务重启中断,可重试"
466
+ _save_json(p, r)
467
467
  _RUNS[r["id"]] = r
468
468
  except Exception:
469
469
  pass
@@ -486,16 +486,16 @@ def load_all():
486
486
 
487
487
  # ---------------------------------------------------------------- 运行(含管理操作)
488
488
 
489
- _RUN_ESTIMATOR = None
490
-
491
-
492
- def set_run_estimator(estimator):
493
- """由应用入口注入 ETA 估算器,保持存储层不反向依赖用量模块。"""
494
- global _RUN_ESTIMATOR
495
- _RUN_ESTIMATOR = estimator if callable(estimator) else None
496
-
497
-
498
- def create_run(kind, title, task_id=None, entry_id=None, op=None):
489
+ _RUN_ESTIMATOR = None
490
+
491
+
492
+ def set_run_estimator(estimator):
493
+ """由应用入口注入 ETA 估算器,保持存储层不反向依赖用量模块。"""
494
+ global _RUN_ESTIMATOR
495
+ _RUN_ESTIMATOR = estimator if callable(estimator) else None
496
+
497
+
498
+ def create_run(kind, title, task_id=None, entry_id=None, op=None):
499
499
  run = {
500
500
  "id": _new_id("r" if kind == "orchestration" else "m"),
501
501
  "kind": kind, # orchestration | mgmt
@@ -505,20 +505,20 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
505
505
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
506
506
  "started_at": None, "ended_at": None,
507
507
  "cost_usd": 0.0, "tokens": 0, "error": "",
508
- "verdict": None, "summary": "",
509
- }
510
- if kind == "orchestration" and task_id:
511
- task = get_task(task_id) or {}
512
- try:
513
- eta = _RUN_ESTIMATOR(
514
- task_type=task.get("type") or "", mode=task.get("mode") or "auto",
515
- thinking=task.get("thinking") or "standard",
516
- rounds=task.get("rounds"))
517
- run["estimated_duration_s"] = eta.get("estimated_duration_s")
518
- run["estimated_p90_s"] = eta.get("p90_duration_s")
519
- run["estimate_source"] = eta.get("duration_source")
520
- except Exception:
521
- pass
508
+ "verdict": None, "summary": "",
509
+ }
510
+ if kind == "orchestration" and task_id:
511
+ task = get_task(task_id) or {}
512
+ try:
513
+ eta = _RUN_ESTIMATOR(
514
+ task_type=task.get("type") or "", mode=task.get("mode") or "auto",
515
+ thinking=task.get("thinking") or "standard",
516
+ rounds=task.get("rounds"))
517
+ run["estimated_duration_s"] = eta.get("estimated_duration_s")
518
+ run["estimated_p90_s"] = eta.get("p90_duration_s")
519
+ run["estimate_source"] = eta.get("duration_source")
520
+ except Exception:
521
+ pass
522
522
  rdir = paths.RUNS_DIR / run["id"]
523
523
  (rdir / "steps").mkdir(parents=True, exist_ok=True)
524
524
  with LOCK:
@@ -539,11 +539,11 @@ def get_run(run_id):
539
539
  return _RUNS.get(run_id)
540
540
 
541
541
 
542
- def list_runs(limit=60):
543
- with LOCK:
544
- ids = sorted(_RUNS.keys(), reverse=True)
545
- selected = ids if limit is None else ids[:limit]
546
- return [_RUNS[i] for i in selected]
542
+ def list_runs(limit=60):
543
+ with LOCK:
544
+ ids = sorted(_RUNS.keys(), reverse=True)
545
+ selected = ids if limit is None else ids[:limit]
546
+ return [_RUNS[i] for i in selected]
547
547
 
548
548
 
549
549
  def latest_run_by_task():
@@ -668,17 +668,17 @@ def task_side(task_id):
668
668
  }
669
669
 
670
670
 
671
- def update_run(run_id, expected_status=None, **fields):
671
+ def update_run(run_id, expected_status=None, **fields):
672
672
  """更新运行字段。expected_status 非 None 时做 CAS(§2C):
673
673
  当前状态不等于 expected_status 则拒绝写入并返回 None,
674
674
  防止陈旧执行方(被取消的 worker、崩溃恢复前的旧线程)覆盖新状态
675
675
  ——防御模式「异步状态不是同步状态」。不传则保持原行为。"""
676
676
  with LOCK:
677
677
  run = _RUNS.get(run_id)
678
- if not run:
679
- return None
680
- if expected_status is not None and run.get("status") != expected_status:
681
- return None
678
+ if not run:
679
+ return None
680
+ if expected_status is not None and run.get("status") != expected_status:
681
+ return None
682
682
  for k in ("error", "summary"):
683
683
  if isinstance(fields.get(k), str):
684
684
  # 错误串多是我们自己拼的 CLI 尾巴(含 ANSI/覆写/乱码墙):
@@ -711,33 +711,33 @@ def update_run(run_id, expected_status=None, **fields):
711
711
  return run
712
712
 
713
713
 
714
- def run_dir(run_id):
715
- return paths.RUNS_DIR / run_id
716
-
717
-
718
- def _schedule_run_dir_cleanup(run_id):
719
- """把运行目录快速移出可见路径,再后台删除大日志目录。
720
-
721
- 删除任务/运行记录不能让 HTTP 请求同步递归扫描数千个日志文件。
722
- 同盘 rename 是 O(1),原路径立即消失;后台失败也不会影响内存状态。
723
- """
724
- source = paths.RUNS_DIR / str(run_id)
725
- if not source.exists():
726
- return
727
- target = paths.RUNS_DIR / (".deleting-%s-%s" % (run_id, secrets.token_hex(4)))
728
- try:
729
- source.rename(target)
730
- except OSError:
731
- target = source
732
-
733
- def _remove():
734
- shutil.rmtree(target, ignore_errors=True)
735
-
736
- threading.Thread(target=_remove, name="codebee-delete-%s" % run_id[:12],
737
- daemon=True).start()
738
-
739
-
740
- def delete_run(run_id):
714
+ def run_dir(run_id):
715
+ return paths.RUNS_DIR / run_id
716
+
717
+
718
+ def _schedule_run_dir_cleanup(run_id):
719
+ """把运行目录快速移出可见路径,再后台删除大日志目录。
720
+
721
+ 删除任务/运行记录不能让 HTTP 请求同步递归扫描数千个日志文件。
722
+ 同盘 rename 是 O(1),原路径立即消失;后台失败也不会影响内存状态。
723
+ """
724
+ source = paths.RUNS_DIR / str(run_id)
725
+ if not source.exists():
726
+ return
727
+ target = paths.RUNS_DIR / (".deleting-%s-%s" % (run_id, secrets.token_hex(4)))
728
+ try:
729
+ source.rename(target)
730
+ except OSError:
731
+ target = source
732
+
733
+ def _remove():
734
+ shutil.rmtree(target, ignore_errors=True)
735
+
736
+ threading.Thread(target=_remove, name="codebee-delete-%s" % run_id[:12],
737
+ daemon=True).start()
738
+
739
+
740
+ def delete_run(run_id):
741
741
  """删除一条运行记录(内存 + 磁盘目录)。返回 (ok, 错误信息)。"""
742
742
  if not _valid_id(run_id):
743
743
  return False, "非法的记录 ID"
@@ -748,7 +748,7 @@ def delete_run(run_id):
748
748
  if run.get("status") in ("queued", "running"):
749
749
  return False, "运行中的记录不能删除,请先取消"
750
750
  del _RUNS[run_id]
751
- _schedule_run_dir_cleanup(run_id)
751
+ _schedule_run_dir_cleanup(run_id)
752
752
  bump_state()
753
753
  return True, ""
754
754
 
@@ -843,36 +843,36 @@ def run_workdir(run_id):
843
843
  _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
844
844
 
845
845
  # 构建产物/依赖缓存目录:里面的文件是工具链再生成的,不是任务成品
846
- _BUILD_DIRS = {
846
+ _BUILD_DIRS = {
847
847
  "target", "build", "dist", "out", "bin", "obj", # 通用构建输出
848
848
  "surefire", "failsafe-reports", "test-output", "reports", # 测试/报告输出
849
849
  ".next", ".nuxt", ".output", ".gradle", ".gradle-home", # 前端/Gradle
850
850
  "__MACOSX",
851
- }
852
-
853
- # CLI 自身的历史/评审中间件不属于用户成果。它们通常落在工作目录根部,
854
- # 只按隐藏目录过滤会漏掉 Aider 的 .aider.* 与流程生成的 *_review.json。
855
- _PROCESS_ARTIFACT_NAMES = {
856
- ".aider.chat.history.md", ".aider.input.history", ".aider.input.history.md",
857
- ".aider.tags.cache.v4", ".aider.tags.cache.v3", ".aider.conf.yml",
858
- }
859
- _PROCESS_ARTIFACT_SUFFIXES = ("_review.json", ".review.json")
860
- _PROCESS_ARTIFACT_PREFIXES = ("tutti_prompt_", "_tutti_prompt_")
861
-
862
-
863
- def _is_process_artifact(rel_name):
864
- """判断工作目录文件是否为 CLI/评审过程产物,而非可交付成果。"""
865
- name = str(rel_name or "").replace("\\", "/")
866
- base = name.rsplit("/", 1)[-1].lower()
867
- if base in _PROCESS_ARTIFACT_NAMES:
868
- return True
869
- if base.startswith(".") and base.startswith(".aider"):
870
- return True
871
- if base.endswith(_PROCESS_ARTIFACT_SUFFIXES):
872
- return True
873
- if base.startswith(_PROCESS_ARTIFACT_PREFIXES):
874
- return True
875
- return False
851
+ }
852
+
853
+ # CLI 自身的历史/评审中间件不属于用户成果。它们通常落在工作目录根部,
854
+ # 只按隐藏目录过滤会漏掉 Aider 的 .aider.* 与流程生成的 *_review.json。
855
+ _PROCESS_ARTIFACT_NAMES = {
856
+ ".aider.chat.history.md", ".aider.input.history", ".aider.input.history.md",
857
+ ".aider.tags.cache.v4", ".aider.tags.cache.v3", ".aider.conf.yml",
858
+ }
859
+ _PROCESS_ARTIFACT_SUFFIXES = ("_review.json", ".review.json")
860
+ _PROCESS_ARTIFACT_PREFIXES = ("tutti_prompt_", "_tutti_prompt_")
861
+
862
+
863
+ def _is_process_artifact(rel_name):
864
+ """判断工作目录文件是否为 CLI/评审过程产物,而非可交付成果。"""
865
+ name = str(rel_name or "").replace("\\", "/")
866
+ base = name.rsplit("/", 1)[-1].lower()
867
+ if base in _PROCESS_ARTIFACT_NAMES:
868
+ return True
869
+ if base.startswith(".") and base.startswith(".aider"):
870
+ return True
871
+ if base.endswith(_PROCESS_ARTIFACT_SUFFIXES):
872
+ return True
873
+ if base.startswith(_PROCESS_ARTIFACT_PREFIXES):
874
+ return True
875
+ return False
876
876
 
877
877
 
878
878
  def task_first_start(task_id, fallback=""):
@@ -933,10 +933,10 @@ def run_artifacts(run_id, limit=200):
933
933
  continue
934
934
  if st.st_mtime < t0:
935
935
  continue
936
- rel_name = str(p.relative_to(root)).replace("\\", "/")
937
- if _is_process_artifact(rel_name):
938
- continue
939
- files.append({"name": rel_name,
936
+ rel_name = str(p.relative_to(root)).replace("\\", "/")
937
+ if _is_process_artifact(rel_name):
938
+ continue
939
+ files.append({"name": rel_name,
940
940
  "size": st.st_size, "mtime": int(st.st_mtime)})
941
941
  if len(files) >= 800: # 防超大目录拖垮接口;截断后再排序取最新
942
942
  break
@@ -1108,7 +1108,7 @@ def delete_runs(run_ids):
1108
1108
  skipped += 1
1109
1109
  continue
1110
1110
  del _RUNS[rid]
1111
- _schedule_run_dir_cleanup(rid)
1111
+ _schedule_run_dir_cleanup(rid)
1112
1112
  deleted += 1
1113
1113
  if deleted:
1114
1114
  bump_state()
@@ -1127,7 +1127,7 @@ def clear_runs():
1127
1127
  for rid in targets:
1128
1128
  del _RUNS[rid]
1129
1129
  for rid in targets:
1130
- _schedule_run_dir_cleanup(rid)
1130
+ _schedule_run_dir_cleanup(rid)
1131
1131
  bump_state()
1132
1132
  return len(targets), skipped
1133
1133
 
@@ -1166,9 +1166,9 @@ def delete_task(task_id):
1166
1166
  del _TASKS[task_id]
1167
1167
  for rid in run_ids:
1168
1168
  _RUNS.pop(rid, None)
1169
- for rid in run_ids:
1170
- _schedule_run_dir_cleanup(rid)
1171
- (paths.TASKS_DIR / (task_id + ".json")).unlink(missing_ok=True)
1169
+ for rid in run_ids:
1170
+ _schedule_run_dir_cleanup(rid)
1171
+ (paths.TASKS_DIR / (task_id + ".json")).unlink(missing_ok=True)
1172
1172
  bump_state()
1173
1173
  return True, ""
1174
1174
 
@@ -1431,6 +1431,43 @@ def rename_task(task_id, title):
1431
1431
  return True, ""
1432
1432
 
1433
1433
 
1434
+ def update_task_params(task_id, patch):
1435
+ """更新任务编排参数(对话条三件套:mode/thinking/direct_provider_id/direct_model)。
1436
+ 只在任务未在跑时生效——运行中改参数不会影响当前执行,静默跳过避免误导。"""
1437
+ if not _valid_id(task_id):
1438
+ return False, "非法的任务 ID"
1439
+ with LOCK:
1440
+ task = _TASKS.get(task_id)
1441
+ if not task:
1442
+ return False, "任务不存在"
1443
+ if task.get("status") in ("queued", "running"):
1444
+ return False, "任务运行中,参数不可改"
1445
+ changed = False
1446
+ mode = str((patch or {}).get("mode") or "").strip()
1447
+ if mode in ("auto", "fast", "expert", "manual"):
1448
+ if task.get("mode") != mode:
1449
+ task["mode"] = mode
1450
+ changed = True
1451
+ thinking = str((patch or {}).get("thinking") or "").strip()
1452
+ if thinking in ("auto", "low", "standard", "high"):
1453
+ if task.get("thinking") != thinking:
1454
+ task["thinking"] = thinking
1455
+ changed = True
1456
+ if task.get("engine") == "direct":
1457
+ pv = str((patch or {}).get("direct_provider_id") or "").strip()[:80]
1458
+ mv = str((patch or {}).get("direct_model") or "").strip()[:160]
1459
+ if mv and not pv:
1460
+ return False, "指定对话模型时必须同时指定厂商"
1461
+ if task.get("direct_provider_id") != pv or task.get("direct_model") != mv:
1462
+ task["direct_provider_id"] = pv
1463
+ task["direct_model"] = mv
1464
+ changed = True
1465
+ if changed:
1466
+ _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
1467
+ bump_state()
1468
+ return True, ""
1469
+
1470
+
1434
1471
  def add_step(run_id, role, agent_id, agent_label, note=""):
1435
1472
  with LOCK:
1436
1473
  run = _RUNS.get(run_id)
package/app/main.py CHANGED
@@ -622,7 +622,7 @@ class Handler(BaseHTTPRequestHandler):
622
622
  return self._api_dir_save()
623
623
  if path == "/api/pick_folder":
624
624
  return self._api_pick_folder()
625
- m = re.match(r"^/api/tasks/([^/]+)/(archive|delete|retry|rename|continue)$", path)
625
+ m = re.match(r"^/api/tasks/([^/]+)/(archive|delete|retry|rename|continue|params)$", path)
626
626
  if m:
627
627
  if m.group(2) == "archive":
628
628
  body = self._body()
@@ -677,6 +677,9 @@ class Handler(BaseHTTPRequestHandler):
677
677
  "run_id": run["id"]})
678
678
  elif m.group(2) == "rename":
679
679
  ok, err = store.rename_task(m.group(1), self._body().get("title"))
680
+ elif m.group(2) == "params":
681
+ # 对话条三件套(mode/thinking/direct 模型)随发随改:只许空闲态改
682
+ ok, err = store.update_task_params(m.group(1), self._body())
680
683
  else:
681
684
  ok, err = store.delete_task(m.group(1))
682
685
  return self._json(400, {"error": err}) if not ok else self._json(200, {"ok": True})
package/app/ui/app.js CHANGED
@@ -6581,6 +6581,12 @@ async function renderChat(run, active) {
6581
6581
  const continueNote = $("rd-chat-continue-note");
6582
6582
  if (continueNote) continueNote.classList.toggle("hidden", !!active);
6583
6583
  if (chatRunId !== run.id) { chatRunId = run.id; chatAtts = []; drawChatAtts(); chatSig = ""; }
6584
+ // 三件套回填:任务对象来自 S.state(渲染时已就位);只在任务未在跑时回显,
6585
+ // 防止轮询把用户正在改的值弹回(select change 重绘弹回教训)
6586
+ if (run.status !== "running" && run.status !== "queued" && S.state) {
6587
+ const task = (S.state.tasks || []).find((x) => x.id === run.task_id);
6588
+ if (task) rdPrefsFill(task);
6589
+ }
6584
6590
  const sig = run.id + "|" + run.status + "|" + (run.steps || []).length +
6585
6591
  "|" + ((run.messages || []).length);
6586
6592
  if (sig === chatSig) return; // 轮询重画去抖:内容没变不重建 DOM(保住输入焦点)
@@ -6856,15 +6862,145 @@ function renderDetailOverview(run, runs, task) {
6856
6862
  box.classList.remove("hidden");
6857
6863
  }
6858
6864
 
6865
+ /* 对话条三件套:与任务参数双向同步(2026-09-21 用户反馈「这里面也需要同步改」)。
6866
+ * 回填自当前任务;改动即写任务(空闲态),发送 /chat 前再兜底同步一次。 */
6867
+ let rdPrefsTaskId = "";
6868
+ let rdModelPv = "", rdModelMv = ""; // 对话条当前模型选择(厂商 id + 模型名)
6869
+
6870
+ function rdPrefsFill(task) {
6871
+ if (!task) return;
6872
+ const set1 = (id, v, allow) => { const el = $(id); if (el && allow.includes(v)) el.value = v; };
6873
+ set1("rd-mode", task.mode, ["auto", "fast", "expert", "manual"]);
6874
+ set1("rd-thinking", task.thinking, ["auto", "low", "standard", "high"]);
6875
+ if (rdPrefsTaskId !== task.id) {
6876
+ rdPrefsTaskId = task.id;
6877
+ rdModelPv = task.direct_provider_id || "";
6878
+ rdModelMv = task.direct_model || "";
6879
+ }
6880
+ ["rd-mode", "rd-thinking"].forEach((id) => syncCmpSelFace(id));
6881
+ rdModelFaceSync();
6882
+ }
6883
+
6884
+ function rdModelFaceSync() {
6885
+ const face = $("rd-model-btn");
6886
+ if (!face) return;
6887
+ if (!rdModelPv) { face.textContent = t("自动推荐"); return; }
6888
+ const p = directProviders().find((x) => x.id === rdModelPv);
6889
+ face.textContent = (p ? (p.name || p.id) : rdModelPv) +
6890
+ (rdModelMv ? "/" + rdModelMv : "/" + t("推荐"));
6891
+ }
6892
+
6893
+ /* 对话条模型菜单(与新建 composer 的 cmp-model-menu 同款两级钻取,独立实例) */
6894
+ let rdMenuProv = "";
6895
+
6896
+ function rdModelMenuRender() {
6897
+ const menu = $("rd-model-menu");
6898
+ if (!menu) return;
6899
+ const chk = '<svg class="ico ti-check" aria-hidden="true"><use href="#i-check"></use></svg>';
6900
+ const arr = '<svg class="ico ti-check" aria-hidden="true"><use href="#i-chevron-r"></use></svg>';
6901
+ if (!rdMenuProv) {
6902
+ menu.innerHTML =
6903
+ '<button type="button" class="type-item" data-p="" data-m=""><span class="ti-body"><span class="ti-name">' +
6904
+ t("自动推荐") + "</span></span>" + (!rdModelPv ? chk : "") + "</button>" +
6905
+ directProviders().map((p) =>
6906
+ '<button type="button" class="type-item" data-p="' + esc(p.id) + '"><span class="ti-body"><span class="ti-name">' +
6907
+ esc(p.name || p.id) + "</span></span>" + (rdModelPv === p.id ? chk : arr) + "</button>").join("") +
6908
+ '<div class="type-menu-foot"><button type="button" class="type-item" data-manage="1"><span class="ti-body"><span class="ti-name">' +
6909
+ t("管理模型…") + "</span></span></button></div>";
6910
+ } else {
6911
+ const p = directProviders().find((x) => x.id === rdMenuProv);
6912
+ const names = p ? (p.models || []).filter((m) => !m.hidden)
6913
+ .map((m) => m.name).filter(Boolean) : [];
6914
+ menu.innerHTML =
6915
+ '<button type="button" class="type-item" data-back="1"><span class="ti-body"><span class="ti-name">‹ ' +
6916
+ esc(p ? (p.name || p.id) : rdMenuProv) + "</span></span></button>" +
6917
+ '<button type="button" class="type-item" data-p="' + esc(rdMenuProv) + '" data-m=""><span class="ti-body"><span class="ti-name">' +
6918
+ t("随厂商推荐") + "</span></span>" + (rdModelPv === rdMenuProv && !rdModelMv ? chk : "") + "</button>" +
6919
+ names.map((n) =>
6920
+ '<button type="button" class="type-item" data-p="' + esc(rdMenuProv) + '" data-m="' + esc(n) + '"><span class="ti-body"><span class="ti-name">' +
6921
+ esc(n) + "</span></span>" + (rdModelPv === rdMenuProv && rdModelMv === n ? chk : "") + "</button>").join("");
6922
+ }
6923
+ }
6924
+
6925
+ function toggleRdModelMenu(force) {
6926
+ const menu = $("rd-model-menu");
6927
+ if (!menu) return;
6928
+ const show = force !== undefined ? force : menu.classList.contains("hidden");
6929
+ if (show) { rdMenuProv = ""; rdModelMenuRender(); }
6930
+ menu.classList.toggle("hidden", !show);
6931
+ }
6932
+ window.toggleRdModelMenu = toggleRdModelMenu;
6933
+
6934
+ (function () {
6935
+ const menu = $("rd-model-menu");
6936
+ if (!menu || menu.dataset.rdBound) return;
6937
+ menu.dataset.rdBound = "1";
6938
+ menu.addEventListener("click", (e) => {
6939
+ e.stopPropagation(); // 同 cmp-model-menu:防「点外面收起」误判(DOM 重写竞态)
6940
+ const b = e.target.closest("[data-p],[data-back],[data-manage]");
6941
+ if (!b) return;
6942
+ if (b.dataset.manage) { toggleRdModelMenu(false); switchTab("models"); return; }
6943
+ if (b.dataset.back) { rdMenuProv = ""; rdModelMenuRender(); return; }
6944
+ if (b.dataset.m === undefined) { rdMenuProv = b.dataset.p; rdModelMenuRender(); return; }
6945
+ rdModelPv = b.dataset.p;
6946
+ rdModelMv = b.dataset.m;
6947
+ rdModelFaceSync();
6948
+ toggleRdModelMenu(false);
6949
+ rdPrefsPush("model");
6950
+ });
6951
+ document.addEventListener("click", (e) => {
6952
+ const wrap = $("rd-model-wrap");
6953
+ if (wrap && !wrap.contains(e.target)) toggleRdModelMenu(false);
6954
+ });
6955
+ document.addEventListener("keydown", (e) => {
6956
+ if (e.key === "Escape") toggleRdModelMenu(false);
6957
+ });
6958
+ })();
6959
+
6960
+ async function rdPrefsPush(what) {
6961
+ // what: mode|thinking|model(变更源);写任务参数(空闲态才允许)
6962
+ const task = (S.lastRun && S.lastRun.task_id && S.state &&
6963
+ (S.state.tasks || []).find((x) => x.id === S.lastRun.task_id)) || null;
6964
+ if (!task) return;
6965
+ const patch = {};
6966
+ if (what === "mode") patch.mode = $("rd-mode").value;
6967
+ if (what === "thinking") patch.thinking = $("rd-thinking").value;
6968
+ if (what === "model") {
6969
+ patch.direct_provider_id = rdModelPv;
6970
+ patch.direct_model = rdModelMv;
6971
+ }
6972
+ try {
6973
+ await api("/api/tasks/" + encodeURIComponent(task.id) + "/params", {
6974
+ method: "POST", operation: false, body: JSON.stringify(patch) });
6975
+ } catch (e) { /* 运行中/网络失败不打断对话,发送前还有兜底 */ }
6976
+ }
6977
+
6978
+ async function rdPrefsPushAll() {
6979
+ // 发送前兜底:三个一起推(/chat 开新一轮前调用;失败静默——值已在前端,
6980
+ // 下一轮 poll 的回填也不会覆盖用户刚选的值)
6981
+ await Promise.all([rdPrefsPush("mode"), rdPrefsPush("thinking"), rdPrefsPush("model")]);
6982
+ }
6983
+
6859
6984
  function drawChatAtts() {
6860
6985
  const box = $("rd-chat-att-list");
6861
6986
  if (!box) return;
6862
- // 点名字开预览(待提交区原文走 /api/attachments/<id>),× 才是移除
6863
- box.innerHTML = chatAtts.map((a, i) =>
6864
- '<span class="att-chip2 att-view" data-att-id="' + esc(a.id || "") +
6865
- '" data-att-name="' + esc(a.name) + '" title="' + esc(t("点击查看")) + '">' + esc(a.name) +
6866
- '<b onclick="chatRemoveAtt(' + i + ')" title="' + esc(t("移除")) + '">×' +
6867
- '</b></span>').join("");
6987
+ // 点名字开预览(待提交区原文走 /api/attachments/<id>),× 才是移除。
6988
+ // 图片直接展示缩略图(2026-09-21 用户反馈「图片要展示出来」)
6989
+ box.innerHTML = chatAtts.map((a, i) => {
6990
+ const isImg = /^image\//i.test(String(a.mime || "")) ||
6991
+ /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(a.name || "");
6992
+ if (isImg && a.id) {
6993
+ const src = "/api/attachments/" + encodeURIComponent(a.id) + qsAuth();
6994
+ return '<span class="att-view rd-att-thumb" data-att-id="' + esc(a.id) +
6995
+ '" data-att-name="' + esc(a.name) + '" title="' + esc(a.name) + '">' +
6996
+ '<img src="' + esc(src) + '" alt="' + esc(a.name) + '" loading="lazy" draggable="false">' +
6997
+ '<b onclick="chatRemoveAtt(' + i + ')" title="' + esc(t("移除")) + '">×</b></span>';
6998
+ }
6999
+ return '<span class="att-chip2 att-view" data-att-id="' + esc(a.id || "") +
7000
+ '" data-att-name="' + esc(a.name) + '" title="' + esc(t("点击查看")) + '">' + esc(a.name) +
7001
+ '<b onclick="chatRemoveAtt(' + i + ')" title="' + esc(t("移除")) + '">×' +
7002
+ '</b></span>';
7003
+ }).join("");
6868
7004
  }
6869
7005
  window.chatRemoveAtt = function (i) { chatAtts.splice(i, 1); drawChatAtts(); };
6870
7006
 
@@ -6923,6 +7059,8 @@ window.chatSend = async function () {
6923
7059
  });
6924
7060
  toast(t("已排队:随下一步一起送达"));
6925
7061
  } else {
7062
+ // 空闲态发送:先把三件套落到任务(变更即推失败时的兜底),再开新一轮
7063
+ await rdPrefsPushAll();
6926
7064
  const r = await api("/api/runs/" + encodeURIComponent(chatRunId) + "/chat", {
6927
7065
  method: "POST",
6928
7066
  body: JSON.stringify({ text, attachments: chatAtts.map((a) => a.id) }),
@@ -7015,6 +7153,9 @@ function bindChat() {
7015
7153
  const chip = e.target.closest("[data-att-id]");
7016
7154
  if (chip) window.chatPendingPopup(chip.dataset.attId, chip.dataset.attName);
7017
7155
  });
7156
+ // 对话条三件套:改动即写任务参数(空闲态),face 跟随
7157
+ $("rd-mode").addEventListener("change", () => { syncCmpSelFace("rd-mode"); rdPrefsPush("mode"); });
7158
+ $("rd-thinking").addEventListener("change", () => { syncCmpSelFace("rd-thinking"); rdPrefsPush("thinking"); });
7018
7159
  // 语音输入(借鉴 paseo voice control):Web Speech API 口述转文字填入输入框。
7019
7160
  // 仅浏览器支持时显示按钮(Chrome/Edge);识别结果追加到光标处,不自动发送。
7020
7161
  bindVoiceInput();
package/app/ui/index.html CHANGED
@@ -492,6 +492,33 @@
492
492
  <button id="rd-chat-voice" class="ghost hidden" data-i18n-title="语音输入(口述转文字填入输入框)" title="语音输入(口述转文字填入输入框)" data-i18n-aria="语音输入" aria-label="语音输入"><svg class="ico" aria-hidden="true"><use href="#i-mic"></use></svg></button>
493
493
  <span id="rd-chat-att-list" class="rd-att-list"></span>
494
494
  <span id="rd-chat-hint" class="rd-hint"></span>
495
+ <!-- 对话条三件套(2026-09-21 用户反馈「这里面也需要同步改」):与
496
+ 新建 composer 同款 pill;改动即写任务参数(空闲态),发送立即生效 -->
497
+ <span class="cmp-sel" data-i18n-title="编排模式" title="编排模式">
498
+ <select id="rd-mode" class="cmp-sel-native" aria-label="编排模式" data-i18n-aria="编排模式">
499
+ <option data-i18n="自动:按任务类型与风险动态决定步骤" value="auto">自动:按任务类型与风险动态决定步骤</option>
500
+ <option data-i18n="快速:少步骤优先,先做确定性验收" value="fast">快速:少步骤优先,先做确定性验收</option>
501
+ <option data-i18n="专家:完整规划、多评审、失败修复与换将" value="expert">专家:完整规划、多评审、失败修复与换将</option>
502
+ <option data-i18n="手动:自行指定实现者与评审组" value="manual">手动:自行指定实现者与评审组</option>
503
+ </select>
504
+ <span class="cmp-sel-face" id="rd-mode-face">自动</span>
505
+ <svg class="ico chev" aria-hidden="true"><use href="#i-chevron-r"></use></svg>
506
+ </span>
507
+ <span class="cmp-sel" data-i18n-title="思考程度" title="思考程度">
508
+ <select id="rd-thinking" class="cmp-sel-native" aria-label="思考程度" data-i18n-aria="思考程度">
509
+ <option data-i18n="标准:平衡质量与速度" value="standard" selected>标准:平衡质量与速度</option>
510
+ <option data-i18n="自动:随任务难度调整" value="auto">自动:随任务难度调整</option>
511
+ <option data-i18n="快速:少推理、优先响应" value="low">快速:少推理、优先响应</option>
512
+ <option data-i18n="深度:更多推理与检查" value="high">深度:更多推理与检查</option>
513
+ </select>
514
+ <span class="cmp-sel-face" id="rd-thinking-face">标准</span>
515
+ <svg class="ico chev" aria-hidden="true"><use href="#i-chevron-r"></use></svg>
516
+ </span>
517
+ <span class="cmp-sel" id="rd-model-wrap" data-i18n-title="对话模型" title="对话模型">
518
+ <button type="button" class="cmp-sel-face cmp-sel-btn" id="rd-model-btn" onclick="toggleRdModelMenu()" aria-haspopup="menu" aria-controls="rd-model-menu">自动推荐</button>
519
+ <svg class="ico chev" aria-hidden="true"><use href="#i-chevron-r"></use></svg>
520
+ <div id="rd-model-menu" class="type-menu hidden" role="menu" aria-label="对话模型" data-i18n-aria="对话模型"></div>
521
+ </span>
495
522
  <button id="rd-chat-send" class="primary round" data-i18n-title="发送" title="发送" data-i18n-aria="发送" aria-label="发送"><svg class="ico" aria-hidden="true"><use href="#i-arrow-up"></use></svg></button>
496
523
  </div>
497
524
  <input id="rd-chat-file" type="file" multiple accept=".png,.jpg,.jpeg,.gif,.webp,.bmp,.svg,.txt,.md,.markdown,.csv,.json,.log,.py,.js,.ts,.html,.css,.xml,.yml,.yaml,.toml,.pdf,.docx,.xlsx,.pptx" class="hidden" />
package/app/ui/style.css CHANGED
@@ -5681,3 +5681,18 @@ button.request-busy > * { opacity: .18; }
5681
5681
  .chat-img img { display: block; width: 96px; height: 96px; object-fit: cover;
5682
5682
  border-radius: 10px; border: 1px solid var(--border); background: var(--panel2); }
5683
5683
  .chat-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
5684
+
5685
+ /* 对话输入条待发图片:小缩略卡(2026-09-21 用户反馈「图片要展示出来」)。
5686
+ * 44×44 内嵌在 rd-att-list 行内,不越出 chat-bar 边框;× 悬浮右上角 */
5687
+ .rd-att-list .rd-att-thumb { position: relative; width: 44px; height: 44px; padding: 0;
5688
+ border: 1px solid var(--border-strong); border-radius: 8px; overflow: visible;
5689
+ display: inline-flex; align-items: center; justify-content: center; cursor: zoom-in; }
5690
+ .rd-att-list .rd-att-thumb img { width: 100%; height: 100%; object-fit: cover;
5691
+ border-radius: 7px; display: block; }
5692
+ .rd-att-list .rd-att-thumb b { position: absolute; top: -7px; right: -7px; z-index: 2;
5693
+ width: 16px; height: 16px; border-radius: 50%; background: var(--panel);
5694
+ border: 1px solid var(--border-strong); display: inline-flex; align-items: center;
5695
+ justify-content: center; font-size: 11px; line-height: 1; }
5696
+ /* 气泡图片缩略图:限制在气泡内(曾压住输入框边框线——2026-09-21 用户反馈) */
5697
+ .chat-img img { box-sizing: border-box; }
5698
+ .chat-bubble { overflow-wrap: anywhere; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {