codebee 0.1.39 → 0.1.41
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 +17 -0
- package/README.md +3 -0
- package/app/core/planner.py +98 -90
- package/app/core/store.py +161 -124
- package/app/main.py +4 -1
- package/app/ui/app.js +147 -6
- package/app/ui/index.html +27 -0
- package/app/ui/style.css +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
6
6
|
|
|
7
7
|
## 未发布
|
|
8
8
|
|
|
9
|
+
## v0.1.41(2026-09-22)
|
|
10
|
+
|
|
11
|
+
### 🛠 问题修复
|
|
12
|
+
|
|
13
|
+
- 断点续跑/重跑更省钱:同一任务再次生成大纲或执行计划时直接命中上次结果的精确缓存(1 小时内有效),不再重复花一次全量规划调用;大纲首试成功但格式不合规时的重试仍会重新生成(保「换一次样本」语义,不会拿同一份坏文本空转)。
|
|
14
|
+
|
|
15
|
+
## v0.1.40(2026-09-21)
|
|
16
|
+
|
|
17
|
+
### 🛠 问题修复
|
|
18
|
+
|
|
19
|
+
- 「厂商/模型」选择器两步手选修复:点厂商只展开该厂商模型列表(不再自动带推荐,模型必须亲手点);修复点厂商后菜单瞬间被收起、模型永远选不上的问题(真机定位:菜单重写 DOM 与「点外面收起」监听的竞态);菜单改为向上弹出,不再被右侧栏遮挡。
|
|
20
|
+
|
|
21
|
+
### ✨ 新功能
|
|
22
|
+
|
|
23
|
+
- 对话输入条与新建任务同款三件套:编排模式 / 思考程度 / 厂商模型选择器直接嵌在输入条工具行,改动即写入任务参数(下一轮对话生效);打开任务时自动回填当前值。
|
|
24
|
+
- 对话输入条的待发图片以缩略图展示(不再只是一串文件名),移除按钮悬浮右上角;气泡内图片缩略图不再压住输入框边框。
|
|
25
|
+
|
|
9
26
|
## v0.1.39(2026-09-21)
|
|
10
27
|
|
|
11
28
|
### 🛠 问题修复
|
package/README.md
CHANGED
|
@@ -23,6 +23,9 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
|
|
|
23
23
|
<!-- relnotes:start -->
|
|
24
24
|
### 🛠 问题修复 · Fixes
|
|
25
25
|
|
|
26
|
+
- 断点续跑/重跑更省钱:同任务再次生成大纲或计划直接命中精确缓存(1 小时内),不重复花全量规划调用;格式不合规的重试仍重新生成。
|
|
27
|
+
- Resuming or re-running a task now hits an exact cache for outline/plan regeneration (within 1 hour), skipping a full planning call; format-failure retries still regenerate.
|
|
28
|
+
|
|
26
29
|
- 修复长任务上下文撑爆后重试仍失败:压缩摘要此前只写日志、没真正替换发往模型的上下文;现在重试用「压缩摘要+最近消息」重建请求并放弃膨胀的旧会话,超长任务能真正续跑。
|
|
27
30
|
- Fixed long-task overflow retries failing again: compaction summaries used to go only to logs. Retries now rebuild the request from the summary plus recent messages and drop the bloated old session.
|
|
28
31
|
|
package/app/core/planner.py
CHANGED
|
@@ -114,13 +114,13 @@ def _log_usage(source, role, task, res, agent=None, tool="", model="", provider=
|
|
|
114
114
|
except Exception:
|
|
115
115
|
pass
|
|
116
116
|
|
|
117
|
-
CODE_PLAN_PROMPT = """你是技术负责人。请把下面的开发目标拆解为 __N__ 个以内、按顺序执行的子任务,
|
|
118
|
-
并判定任务难度和后续质量步骤。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
119
|
-
{"difficulty": "easy 或 hard", "workflow": {"review_required": true, "max_repair_rounds": 0, "allow_switch": false}, "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令", "files": ["涉及的文件路径"]}]}
|
|
120
|
-
难度判定:常规增删改查/小函数/格式调整 = easy;跨模块改动/架构调整/复杂算法/安全相关 = hard。
|
|
121
|
-
质量步骤判定:涉及安全、数据迁移、并发、权限、公共 API 或跨模块改动时必须评审;
|
|
122
|
-
有明确自动验收的低风险小改可不做模型评审。max_repair_rounds 取 0-2,只有复杂任务才建议换将。
|
|
123
|
-
子任务粒度要可独立验证;最后一个子任务必须包含整体联调/收尾。
|
|
117
|
+
CODE_PLAN_PROMPT = """你是技术负责人。请把下面的开发目标拆解为 __N__ 个以内、按顺序执行的子任务,
|
|
118
|
+
并判定任务难度和后续质量步骤。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
119
|
+
{"difficulty": "easy 或 hard", "workflow": {"review_required": true, "max_repair_rounds": 0, "allow_switch": false}, "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令", "files": ["涉及的文件路径"]}]}
|
|
120
|
+
难度判定:常规增删改查/小函数/格式调整 = easy;跨模块改动/架构调整/复杂算法/安全相关 = hard。
|
|
121
|
+
质量步骤判定:涉及安全、数据迁移、并发、权限、公共 API 或跨模块改动时必须评审;
|
|
122
|
+
有明确自动验收的低风险小改可不做模型评审。max_repair_rounds 取 0-2,只有复杂任务才建议换将。
|
|
123
|
+
子任务粒度要可独立验证;最后一个子任务必须包含整体联调/收尾。
|
|
124
124
|
|
|
125
125
|
## 先探索再计划(重要,借鉴 OpenSpec explore)
|
|
126
126
|
拆解之前先用你的读文件/搜索工具**实际查看工作目录**,找到目标相关的真实文件与函数,再据此拆解。
|
|
@@ -137,12 +137,12 @@ __CONTEXT__
|
|
|
137
137
|
## 验收命令(最终必须通过)
|
|
138
138
|
__VERIFY__"""
|
|
139
139
|
|
|
140
|
-
REVIEW_OUTLINE_PROMPT = """你是内容主编。请为下面的创作任务拟一份写作大纲(要点列表,3-8 条),
|
|
141
|
-
并按内容风险建议后续质量步骤。
|
|
142
|
-
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
143
|
-
{"outline": ["要点1", "要点2", ...], "workflow": {"reviewers": 1, "review_rounds": 1}}
|
|
144
|
-
reviewers 取 1-2,review_rounds 取 1-3;研究报告、技术方案、长文和高发布门槛应增加,
|
|
145
|
-
普通邮件、短翻译、工作汇报等低风险短内容保持 1。
|
|
140
|
+
REVIEW_OUTLINE_PROMPT = """你是内容主编。请为下面的创作任务拟一份写作大纲(要点列表,3-8 条),
|
|
141
|
+
并按内容风险建议后续质量步骤。
|
|
142
|
+
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
143
|
+
{"outline": ["要点1", "要点2", ...], "workflow": {"reviewers": 1, "review_rounds": 1}}
|
|
144
|
+
reviewers 取 1-2,review_rounds 取 1-3;研究报告、技术方案、长文和高发布门槛应增加,
|
|
145
|
+
普通邮件、短翻译、工作汇报等低风险短内容保持 1。
|
|
146
146
|
|
|
147
147
|
## 创作任务
|
|
148
148
|
__GOAL__
|
|
@@ -327,9 +327,14 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
327
327
|
# 否则 stop_reason=max_tokens、正文为空(实测 2048 全被思考吞掉)
|
|
328
328
|
# 流式回调把增量实时写进步骤日志,直连生成不再是一行标题的黑箱
|
|
329
329
|
cb = _log_streamer(log_path)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
330
|
+
# §07 T2.2 幂等精确缓存(TTL 1h):断点续跑/重跑同任务时大纲
|
|
331
|
+
# prompt 逐字节相同,命中省一次全量规划调用。第 2 次尝试故意
|
|
332
|
+
# 旁路缓存——首试 ok 但 JSON 不合法时,重试要的是「再抽一次样
|
|
333
|
+
# 本」而不是同一份坏文本。
|
|
334
|
+
res = modelhub.chat(prov["id"], model, prompt,
|
|
335
|
+
max_tokens=16000, timeout=300, on_delta=cb,
|
|
336
|
+
reasoning_effort=_reasoning_effort(task),
|
|
337
|
+
cache_ttl=3600 if _attempt == 1 else 0)
|
|
333
338
|
cb.flush()
|
|
334
339
|
_log_usage("outline", "outline", task, res, model=model,
|
|
335
340
|
provider=prov.get("name", prov.get("id", "")),
|
|
@@ -409,56 +414,56 @@ def _norm_subtasks(data):
|
|
|
409
414
|
return steps or None
|
|
410
415
|
|
|
411
416
|
|
|
412
|
-
def _plan_difficulty(data):
|
|
417
|
+
def _plan_difficulty(data):
|
|
413
418
|
d = str((data or {}).get("difficulty") or "").lower()
|
|
414
|
-
return d if d in ("easy", "hard") else None
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
def _plan_workflow(data):
|
|
418
|
-
"""规范化规划器给出的后续步骤建议;最终安全下限由任务编译器裁决。"""
|
|
419
|
-
raw = (data or {}).get("workflow")
|
|
420
|
-
if not isinstance(raw, dict):
|
|
421
|
-
return None
|
|
422
|
-
out = {}
|
|
423
|
-
if isinstance(raw.get("review_required"), bool):
|
|
424
|
-
out["review_required"] = raw["review_required"]
|
|
425
|
-
try:
|
|
426
|
-
if "max_repair_rounds" in raw:
|
|
427
|
-
out["max_repair_rounds"] = max(0, min(2, int(raw["max_repair_rounds"])))
|
|
428
|
-
except (TypeError, ValueError):
|
|
429
|
-
pass
|
|
430
|
-
if isinstance(raw.get("allow_switch"), bool):
|
|
431
|
-
out["allow_switch"] = raw["allow_switch"]
|
|
432
|
-
return out or None
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
def _orchestrator():
|
|
436
|
-
"""编排者可用时返回 (provider, model),否则 None。"""
|
|
437
|
-
try:
|
|
438
|
-
return modelhub.resolve_orchestrator()
|
|
439
|
-
except Exception:
|
|
440
|
-
return None
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
def _reasoning_effort(task):
|
|
444
|
-
"""把任务级思考程度映射为直连编排者支持的 reasoning 参数。"""
|
|
445
|
-
task = task if isinstance(task, dict) else {}
|
|
446
|
-
selected = str(task.get("thinking") or "standard").strip().lower()
|
|
447
|
-
if selected in ("low", "standard", "high"):
|
|
448
|
-
return {"low": "low", "standard": "medium", "high": "high"}[selected]
|
|
449
|
-
mode = str(task.get("mode") or "auto").lower()
|
|
450
|
-
difficulty = str(task.get("difficulty") or "auto").lower()
|
|
451
|
-
ttype = str(task.get("type") or "").lower()
|
|
452
|
-
try:
|
|
453
|
-
threshold = float(task.get("threshold") or 7.0)
|
|
454
|
-
except (TypeError, ValueError):
|
|
455
|
-
threshold = 7.0
|
|
456
|
-
if mode == "fast" or difficulty == "easy" or threshold <= 6.0:
|
|
457
|
-
return "low"
|
|
458
|
-
if (mode == "expert" or difficulty == "hard" or threshold >= 8.5
|
|
459
|
-
or ttype in ("research", "tech_proposal", "serial_novel")):
|
|
460
|
-
return "high"
|
|
461
|
-
return "medium"
|
|
419
|
+
return d if d in ("easy", "hard") else None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _plan_workflow(data):
|
|
423
|
+
"""规范化规划器给出的后续步骤建议;最终安全下限由任务编译器裁决。"""
|
|
424
|
+
raw = (data or {}).get("workflow")
|
|
425
|
+
if not isinstance(raw, dict):
|
|
426
|
+
return None
|
|
427
|
+
out = {}
|
|
428
|
+
if isinstance(raw.get("review_required"), bool):
|
|
429
|
+
out["review_required"] = raw["review_required"]
|
|
430
|
+
try:
|
|
431
|
+
if "max_repair_rounds" in raw:
|
|
432
|
+
out["max_repair_rounds"] = max(0, min(2, int(raw["max_repair_rounds"])))
|
|
433
|
+
except (TypeError, ValueError):
|
|
434
|
+
pass
|
|
435
|
+
if isinstance(raw.get("allow_switch"), bool):
|
|
436
|
+
out["allow_switch"] = raw["allow_switch"]
|
|
437
|
+
return out or None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _orchestrator():
|
|
441
|
+
"""编排者可用时返回 (provider, model),否则 None。"""
|
|
442
|
+
try:
|
|
443
|
+
return modelhub.resolve_orchestrator()
|
|
444
|
+
except Exception:
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _reasoning_effort(task):
|
|
449
|
+
"""把任务级思考程度映射为直连编排者支持的 reasoning 参数。"""
|
|
450
|
+
task = task if isinstance(task, dict) else {}
|
|
451
|
+
selected = str(task.get("thinking") or "standard").strip().lower()
|
|
452
|
+
if selected in ("low", "standard", "high"):
|
|
453
|
+
return {"low": "low", "standard": "medium", "high": "high"}[selected]
|
|
454
|
+
mode = str(task.get("mode") or "auto").lower()
|
|
455
|
+
difficulty = str(task.get("difficulty") or "auto").lower()
|
|
456
|
+
ttype = str(task.get("type") or "").lower()
|
|
457
|
+
try:
|
|
458
|
+
threshold = float(task.get("threshold") or 7.0)
|
|
459
|
+
except (TypeError, ValueError):
|
|
460
|
+
threshold = 7.0
|
|
461
|
+
if mode == "fast" or difficulty == "easy" or threshold <= 6.0:
|
|
462
|
+
return "low"
|
|
463
|
+
if (mode == "expert" or difficulty == "hard" or threshold >= 8.5
|
|
464
|
+
or ttype in ("research", "tech_proposal", "serial_novel")):
|
|
465
|
+
return "high"
|
|
466
|
+
return "medium"
|
|
462
467
|
|
|
463
468
|
|
|
464
469
|
def make_code_plan(task, planner_agent, workdir, ev=None, resume=None, log_path=None):
|
|
@@ -482,10 +487,10 @@ def make_code_plan(task, planner_agent, workdir, ev=None, resume=None, log_path=
|
|
|
482
487
|
_log_usage("plan", "plan", task, res, agent=planner_agent)
|
|
483
488
|
data = runner.extract_json(res.get("text") or "")
|
|
484
489
|
steps = _norm_subtasks(data)
|
|
485
|
-
if steps:
|
|
486
|
-
return {"source": "llm(%s)" % planner_agent["id"], "steps": steps,
|
|
487
|
-
"difficulty": _plan_difficulty(data),
|
|
488
|
-
"workflow": _plan_workflow(data)}
|
|
490
|
+
if steps:
|
|
491
|
+
return {"source": "llm(%s)" % planner_agent["id"], "steps": steps,
|
|
492
|
+
"difficulty": _plan_difficulty(data),
|
|
493
|
+
"workflow": _plan_workflow(data)}
|
|
489
494
|
return _fallback_code_plan(task, "LLM 计划解析失败,退化为单步模板")
|
|
490
495
|
|
|
491
496
|
|
|
@@ -496,13 +501,15 @@ def _fallback_code_plan(task, note):
|
|
|
496
501
|
|
|
497
502
|
def _orch_code_plan(task, prov, model, log_path=None):
|
|
498
503
|
cb = _log_streamer(log_path)
|
|
504
|
+
# §07 T2.2:代码计划同任务同 prompt(断点续跑/重试),TTL 1h 精确缓存
|
|
499
505
|
res = modelhub.chat(prov["id"], model,
|
|
500
506
|
(CODE_PLAN_PROMPT.replace("__N__", str(MAX_SUBTASKS))
|
|
501
507
|
.replace("__GOAL__", task["goal"])
|
|
502
508
|
.replace("__CONTEXT__", task.get("context") or "(无)")
|
|
503
509
|
.replace("__VERIFY__", task.get("verify_command") or "(未配置)")),
|
|
504
|
-
max_tokens=8000, timeout=300, on_delta=cb,
|
|
505
|
-
reasoning_effort=_reasoning_effort(task)
|
|
510
|
+
max_tokens=8000, timeout=300, on_delta=cb,
|
|
511
|
+
reasoning_effort=_reasoning_effort(task),
|
|
512
|
+
cache_ttl=3600)
|
|
506
513
|
cb.flush()
|
|
507
514
|
_log_usage("plan", "plan", task, res, model=model,
|
|
508
515
|
provider=prov.get("name", prov.get("id", "")))
|
|
@@ -515,9 +522,9 @@ def _orch_code_plan(task, prov, model, log_path=None):
|
|
|
515
522
|
if not steps:
|
|
516
523
|
_append_log(log_path, "编排者计划返回内容无法解析为子任务,回退 CLI")
|
|
517
524
|
return None
|
|
518
|
-
return {"source": "编排者(%s · %s)" % (prov.get("name", prov["id"]), model),
|
|
519
|
-
"steps": steps, "difficulty": _plan_difficulty(data),
|
|
520
|
-
"workflow": _plan_workflow(data)}
|
|
525
|
+
return {"source": "编排者(%s · %s)" % (prov.get("name", prov["id"]), model),
|
|
526
|
+
"steps": steps, "difficulty": _plan_difficulty(data),
|
|
527
|
+
"workflow": _plan_workflow(data)}
|
|
521
528
|
|
|
522
529
|
|
|
523
530
|
def make_review_outline(task):
|
|
@@ -530,8 +537,9 @@ def make_review_outline(task):
|
|
|
530
537
|
(REVIEW_OUTLINE_PROMPT
|
|
531
538
|
.replace("__GOAL__", task["goal"])
|
|
532
539
|
.replace("__CONTEXT__", task.get("context") or "(无)")),
|
|
533
|
-
max_tokens=8000, timeout=300,
|
|
534
|
-
reasoning_effort=_reasoning_effort(task)
|
|
540
|
+
max_tokens=8000, timeout=300,
|
|
541
|
+
reasoning_effort=_reasoning_effort(task),
|
|
542
|
+
cache_ttl=3600) # §07 T2.2:同任务重试幂等,TTL 1h
|
|
535
543
|
_log_usage("outline", "outline", task, res, model=model,
|
|
536
544
|
provider=prov.get("name", prov.get("id", "")))
|
|
537
545
|
if not res["ok"]:
|
|
@@ -541,20 +549,20 @@ def make_review_outline(task):
|
|
|
541
549
|
if not isinstance(outline, list):
|
|
542
550
|
return None
|
|
543
551
|
items = [str(x).strip()[:120] for x in outline if str(x).strip()][:8]
|
|
544
|
-
if not items:
|
|
545
|
-
return None
|
|
546
|
-
workflow = data.get("workflow") if isinstance(data.get("workflow"), dict) else {}
|
|
547
|
-
try:
|
|
548
|
-
reviewers = max(1, min(2, int(workflow.get("reviewers") or 1)))
|
|
549
|
-
except (TypeError, ValueError):
|
|
550
|
-
reviewers = 1
|
|
551
|
-
try:
|
|
552
|
-
review_rounds = max(1, min(3, int(workflow.get("review_rounds") or 1)))
|
|
553
|
-
except (TypeError, ValueError):
|
|
554
|
-
review_rounds = 1
|
|
555
|
-
return {"source": "编排者(%s · %s)" % (prov.get("name", prov["id"]), model),
|
|
556
|
-
"items": items,
|
|
557
|
-
"workflow": {"reviewers": reviewers, "review_rounds": review_rounds}}
|
|
552
|
+
if not items:
|
|
553
|
+
return None
|
|
554
|
+
workflow = data.get("workflow") if isinstance(data.get("workflow"), dict) else {}
|
|
555
|
+
try:
|
|
556
|
+
reviewers = max(1, min(2, int(workflow.get("reviewers") or 1)))
|
|
557
|
+
except (TypeError, ValueError):
|
|
558
|
+
reviewers = 1
|
|
559
|
+
try:
|
|
560
|
+
review_rounds = max(1, min(3, int(workflow.get("review_rounds") or 1)))
|
|
561
|
+
except (TypeError, ValueError):
|
|
562
|
+
review_rounds = 1
|
|
563
|
+
return {"source": "编排者(%s · %s)" % (prov.get("name", prov["id"]), model),
|
|
564
|
+
"items": items,
|
|
565
|
+
"workflow": {"reviewers": reviewers, "review_rounds": review_rounds}}
|
|
558
566
|
|
|
559
567
|
|
|
560
568
|
def make_novel_plan(task, author, critics):
|
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
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
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