feat: 初步实现aide decide

This commit is contained in:
2025-12-15 02:08:06 +08:00
parent b67ff60c70
commit 1381e8c7cd
16 changed files with 1649 additions and 13 deletions

8
.cache/p2/1.md Normal file
View File

@@ -0,0 +1,8 @@
1. 首先阅读 @statements/optimize.md还有 @README.md 并根据其指引了解跟aide decide有关的所有信息
2. 对aide decide开始详细设计我将在完成设计后将所有设计文档打包交给其他开发人员进行开发
- 设计文档中要有一个用于指导接手人员的入口文档,其中要说明:需要了解哪些信息、需要做什么事
对开发文档完善程度的预期是可以仅靠整套文档和可能含有的其他附属文件的内容我将会打包aide-program目录以此为确定目标和完整指导aide-program/docs/ 目录下的相关文档开始目标程序的实际开发工作直到构建产出程序和通过测试运行运行aide decide -h 及其一系列命令与参数)
切记非常重要开发文档中不允许给出代码片段、实现代码最多只能出现类型与数据结构伪代码原型、方法签名、业务逻辑流程的plantuml流程图、API约定

View File

@@ -1,5 +1 @@
""" """Aide 命令行工具包:环境检测、流程追踪与待定项确认。"""
Aide 命令行工具包。
当前版本聚焦基础环境检测与配置管理flow/decide 功能尚未实现。
"""

View File

@@ -42,6 +42,12 @@ path = "requirements.txt"
[flow] [flow]
phases = ["task-optimize", "flow-design", "impl", "verify", "docs", "finish"] phases = ["task-optimize", "flow-design", "impl", "verify", "docs", "finish"]
[decide]
# HTTP 服务起始端口
port = 3721
# 超时时间0 表示不超时
timeout = 0
""" """

View File

@@ -0,0 +1,9 @@
"""aide decide 模块入口。"""
from .cli import cmd_decide, cmd_decide_result, cmd_decide_submit
__all__ = [
"cmd_decide",
"cmd_decide_submit",
"cmd_decide_result",
]

View File

@@ -0,0 +1,99 @@
"""CLI 入口:解析参数并调度 decide 功能。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from aide.decide.errors import DecideError
from aide.decide.server import DecideServer
from aide.decide.storage import DecideStorage
from aide.decide.types import DecideInput
def cmd_decide(args) -> bool:
"""aide decide 统一入口。"""
if getattr(args, "data", None) == "result":
return cmd_decide_result()
if getattr(args, "data", None) is None:
_print_error("缺少参数: 需要传入 JSON 数据或 result")
return False
return cmd_decide_submit(args.data)
def cmd_decide_submit(json_data: str) -> bool:
"""提交待定项并启动 Web 服务。"""
root = Path.cwd()
storage = DecideStorage(root)
try:
raw = json.loads(json_data)
except json.JSONDecodeError as exc:
_print_error(f"JSON 解析失败: {exc}", "检查 JSON 格式是否正确")
return False
try:
decide_input = DecideInput.from_dict(raw)
except DecideError as exc:
_print_error(f"数据验证失败: {exc}", "检查必填字段是否完整")
return False
try:
storage.save_pending(decide_input)
except DecideError as exc:
_print_error(str(exc))
return False
server = DecideServer(root, storage)
return server.start()
def cmd_decide_result() -> bool:
"""读取最新决策结果并输出 JSON。"""
root = Path.cwd()
storage = DecideStorage(root)
try:
pending = storage.load_pending()
except DecideError as exc:
_print_error(str(exc))
return False
if pending is None:
_print_error("未找到待定项数据", "请先执行 aide decide '<json>'")
return False
session_id = pending.meta.session_id if pending.meta else None
if not session_id:
_print_error("决策结果已过期", "pending.json 已被更新,请重新执行 aide decide '<json>'")
return False
try:
result = storage.load_result()
except DecideError as exc:
_print_error(str(exc))
return False
if result is None:
has_history = any(
path.is_file()
and path.name.endswith(".json")
and path.name != "pending.json"
for path in storage.decisions_dir.glob("*.json")
)
if has_history:
_print_error("决策结果已过期", "pending.json 已被更新,请重新执行 aide decide '<json>'")
else:
_print_error("尚无决策结果", "请等待用户在 Web 界面完成操作")
return False
payload = json.dumps(result.to_dict(), ensure_ascii=False, separators=(",", ":"))
print(payload)
return True
def _print_error(message: str, suggestion: str | None = None) -> None:
sys.stderr.write(f"{message}\n")
if suggestion:
sys.stderr.write(f" 建议: {suggestion}\n")

View File

@@ -0,0 +1,6 @@
"""decide 模块内部错误类型。"""
class DecideError(RuntimeError):
"""用于将内部错误转换为统一的 CLI 输出。"""

View File

@@ -0,0 +1,154 @@
"""HTTP 请求处理:静态资源与 API。"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Callable
from urllib.parse import urlparse
from aide.decide.errors import DecideError
from aide.decide.storage import DecideStorage
from aide.decide.types import DecideInput, DecideOutput
Response = tuple[int, dict[str, str], bytes]
class DecideHandlers:
"""处理 HTTP 请求,返回 (状态码, 头, 响应体)。"""
def __init__(self, storage: DecideStorage, web_dir: Path, stop_callback: Callable[[str], None]):
self.storage = storage
self.web_dir = web_dir
self.stop_callback = stop_callback
def handle(self, method: str, path: str, body: bytes) -> Response:
parsed = urlparse(path)
route = parsed.path
if method == "OPTIONS":
return 200, self._cors_headers({"Content-Type": "text/plain"}), b""
if method == "GET":
if route in {"/", "/index.html"}:
return self.handle_index()
if route == "/style.css":
return self.handle_static("style.css", "text/css; charset=utf-8")
if route == "/app.js":
return self.handle_static("app.js", "application/javascript; charset=utf-8")
if route == "/api/items":
return self.handle_get_items()
return self._not_found()
if method == "POST" and route == "/api/submit":
return self.handle_submit(body)
if route in {"/api/items", "/api/submit"}:
return self._method_not_allowed()
return self._not_found()
def handle_index(self) -> Response:
return self._read_file("index.html", "text/html; charset=utf-8")
def handle_static(self, filename: str, content_type: str) -> Response:
safe_name = Path(filename).name
if safe_name != filename:
return self._not_found()
return self._read_file(safe_name, content_type)
def handle_get_items(self) -> Response:
try:
pending = self.storage.load_pending()
except DecideError as exc:
return self._server_error("无法读取待定项数据", str(exc))
if pending is None:
return self._server_error("无法读取待定项数据", "文件不存在或格式错误")
body = json.dumps(pending.to_dict(include_meta=False), ensure_ascii=False).encode("utf-8")
headers = self._cors_headers({"Content-Type": "application/json; charset=utf-8"})
return 200, headers, body
def handle_submit(self, body: bytes) -> Response:
try:
pending = self.storage.load_pending()
except DecideError as exc:
return self._server_error("保存失败", str(exc))
if pending is None:
return self._server_error("保存失败", "未找到待定项数据")
try:
payload = json.loads(body.decode("utf-8"))
output = DecideOutput.from_dict(payload)
self._validate_decisions(pending, output)
except DecideError as exc:
return self._bad_request("决策数据无效", str(exc))
except Exception as exc:
return self._bad_request("决策数据无效", str(exc))
try:
self.storage.save_result(output)
except DecideError as exc:
return self._server_error("保存失败", str(exc))
# 保存成功后触发关闭
self.stop_callback("completed")
headers = self._cors_headers({"Content-Type": "application/json; charset=utf-8"})
body = json.dumps({"success": True, "message": "决策已保存"}, ensure_ascii=False).encode("utf-8")
return 200, headers, body
def _validate_decisions(self, pending: DecideInput, output: DecideOutput) -> None:
items_by_id = {item.id: item for item in pending.items}
seen: set[int] = set()
for decision in output.decisions:
if decision.id in seen:
raise DecideError(f"待定项 {decision.id} 的决策重复")
seen.add(decision.id)
item = items_by_id.get(decision.id)
if item is None:
raise DecideError(f"存在未知的待定项 {decision.id}")
option_values = {opt.value for opt in item.options}
if decision.chosen not in option_values:
raise DecideError(f"待定项 {decision.id} 的决策值无效: {decision.chosen}")
missing = [str(item_id) for item_id in items_by_id.keys() if item_id not in seen]
if missing:
raise DecideError(f"缺少待定项 {', '.join(missing)} 的决策")
def _read_file(self, filename: str, content_type: str) -> Response:
path = self.web_dir / filename
if not path.exists():
return self._server_error("读取静态资源失败", f"{filename} 不存在")
try:
data = path.read_bytes()
except Exception as exc:
return self._server_error("读取静态资源失败", str(exc))
headers = self._cors_headers({"Content-Type": content_type})
return 200, headers, data
def _not_found(self) -> Response:
headers = self._cors_headers({"Content-Type": "text/plain; charset=utf-8"})
return 404, headers, b"Not Found"
def _method_not_allowed(self) -> Response:
headers = self._cors_headers({"Content-Type": "text/plain; charset=utf-8"})
return 405, headers, b"Method Not Allowed"
def _bad_request(self, message: str, detail: str) -> Response:
payload = json.dumps({"error": message, "detail": detail}, ensure_ascii=False).encode("utf-8")
headers = self._cors_headers({"Content-Type": "application/json; charset=utf-8"})
return 400, headers, payload
def _server_error(self, message: str, detail: str) -> Response:
payload = json.dumps({"error": message, "detail": detail}, ensure_ascii=False).encode("utf-8")
headers = self._cors_headers({"Content-Type": "application/json; charset=utf-8"})
return 500, headers, payload
def _cors_headers(self, base: dict[str, str] | None = None) -> dict[str, str]:
headers = base.copy() if base else {}
headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
headers["Access-Control-Allow-Headers"] = "Content-Type"
return headers

View File

@@ -0,0 +1,202 @@
"""HTTP 服务器生命周期管理。"""
from __future__ import annotations
import json
import socket
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from aide.core import output
from aide.core.config import ConfigManager
from aide.decide.errors import DecideError
from aide.decide.handlers import DecideHandlers, Response
from aide.decide.storage import DecideStorage
class DecideHTTPServer(HTTPServer):
"""附带处理器实例的 HTTPServer。"""
def __init__(self, server_address, RequestHandlerClass, handlers: DecideHandlers):
self.handlers = handlers
super().__init__(server_address, RequestHandlerClass)
class DecideServer:
"""启动、监听与关闭 HTTP 服务。"""
def __init__(self, root, storage: DecideStorage):
self.root = root
self.storage = storage
self.port = 3721
self.timeout = 0
self.web_dir = root / "aide" / "decide" / "web"
self.should_close = False
self.close_reason: str | None = None
self.httpd: DecideHTTPServer | None = None
def start(self) -> bool:
try:
config = ConfigManager(self.root).load_config()
start_port = _get_int(config, "decide", "port", default=3721)
self.timeout = _get_int(config, "decide", "timeout", default=0)
end_port = start_port + 9
available = self._find_available_port(start_port)
if available is None:
output.err(f"无法启动服务: 端口 {start_port}-{end_port} 均被占用")
output.info("建议: 关闭占用端口的程序,或在配置中指定其他端口")
return False
self.port = available
handlers = DecideHandlers(
storage=self.storage,
web_dir=self.web_dir,
stop_callback=self.stop,
)
RequestHandler = self._build_request_handler(handlers)
self.httpd = DecideHTTPServer(("127.0.0.1", self.port), RequestHandler, handlers)
self.httpd.timeout = 1.0
output.info("Web 服务已启动")
output.info(f"请访问: http://localhost:{self.port}")
output.info("等待用户完成决策...")
self._serve_forever()
if self.close_reason == "completed":
output.ok("决策已完成")
return True
if self.close_reason == "timeout":
output.warn("服务超时,已自动关闭")
return True
if self.close_reason == "interrupted":
output.warn("服务已中断")
return True
return True
except DecideError as exc:
output.err(str(exc))
return False
def stop(self, reason: str) -> None:
if self.should_close:
return
self.should_close = True
self.close_reason = reason
def _find_available_port(self, start: int) -> int | None:
attempts = 10
for offset in range(attempts):
port = start + offset
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind(("127.0.0.1", port))
return port
except OSError:
continue
return None
def _serve_forever(self) -> None:
if self.httpd is None:
return
deadline = None if self.timeout <= 0 else time.time() + self.timeout
try:
while not self.should_close:
if deadline is not None and time.time() >= deadline:
self.stop("timeout")
break
self.httpd.handle_request()
except KeyboardInterrupt:
self.stop("interrupted")
finally:
try:
self.httpd.server_close()
except Exception:
pass
def _build_request_handler(self, handlers: DecideHandlers):
server = self
class RequestHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
self._dispatch("GET")
def do_POST(self):
self._dispatch("POST")
def do_OPTIONS(self):
self._dispatch("OPTIONS")
def _dispatch(self, method: str) -> None:
length = self.headers.get("Content-Length")
body = b""
if method == "POST":
try:
content_length = int(length) if length else 0
except ValueError:
self._send_response(
(400, handlers._cors_headers({"Content-Type": "application/json; charset=utf-8"}), b'{"error":"决策数据无效","detail":"无效的 Content-Length"}')
)
return
if content_length > 1024 * 1024:
self._send_response(
(
413,
handlers._cors_headers({"Content-Type": "application/json; charset=utf-8"}),
b'{"error":"请求体过大","detail":"单次提交限制 1MB"}',
)
)
return
body = self.rfile.read(content_length)
try:
response = handlers.handle(method, self.path, body)
except Exception as exc: # pragma: no cover - 兜底防御
payload = (
b'{"error":"服务器内部错误","detail":'
+ json.dumps(str(exc), ensure_ascii=False).encode("utf-8")
+ b"}"
)
response = (
500,
handlers._cors_headers({"Content-Type": "application/json; charset=utf-8"}),
payload,
)
self._send_response(response)
if server.should_close:
# 已由 handlers 设置关闭标志,等待当前请求结束
pass
def log_message(self, format: str, *args) -> None: # noqa: A003
# 静默日志,避免干扰 CLI 输出
return
def _send_response(self, response: Response) -> None:
status, headers, body = response
self.send_response(status)
for key, value in headers.items():
self.send_header(key, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
return RequestHandler
def _get_int(config: dict, section: str, key: str, default: int) -> int:
try:
section_data = config.get(section, {}) if isinstance(config, dict) else {}
value = section_data.get(key, default)
if isinstance(value, bool):
return default
if isinstance(value, (int, float)):
as_int = int(value)
return as_int if as_int >= 0 else default
except Exception:
return default
return default

View File

@@ -0,0 +1,117 @@
"""决策数据的读写与原子化保存。"""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any
from aide.decide.errors import DecideError
from aide.decide.types import DecideInput, DecideOutput, DecisionRecord, MetaInfo
from aide.flow.utils import now_task_id
class DecideStorage:
"""管理 pending.json 与历史记录文件。"""
def __init__(self, root: Path):
self.root = root
self.aide_dir = self.root / ".aide"
self.decisions_dir = self.aide_dir / "decisions"
self.pending_path = self.decisions_dir / "pending.json"
def ensure_ready(self) -> None:
if not self.aide_dir.exists():
raise DecideError(".aide 目录不存在,请先执行 aide init")
self.decisions_dir.mkdir(parents=True, exist_ok=True)
def save_pending(self, data: DecideInput) -> str:
"""保存待定项数据并生成 session_id。"""
self.ensure_ready()
session_id = now_task_id()
created_at = datetime.now().astimezone().isoformat(timespec="seconds")
meta = MetaInfo(created_at=created_at, session_id=session_id)
payload = data.with_meta(meta)
self._save_atomic(self.pending_path, payload.to_dict())
return session_id
def load_pending(self) -> DecideInput | None:
"""读取 pending.json若不存在返回 None。"""
self.ensure_ready()
if not self.pending_path.exists():
return None
data = self._load_json(self.pending_path)
return DecideInput.from_dict(data)
def get_session_id(self) -> str | None:
pending = self.load_pending()
if pending is None:
return None
if pending.meta is None:
raise DecideError("pending.json 缺少 _meta.session_id")
return pending.meta.session_id
def save_result(self, output: DecideOutput) -> None:
"""保存用户决策为历史记录。"""
pending = self.load_pending()
if pending is None:
raise DecideError("未找到待定项数据,请先执行 aide decide '<json>'")
if pending.meta is None:
raise DecideError("pending.json 缺少 _meta.session_id")
record = DecisionRecord(
input=pending.without_meta(),
output=output,
completed_at=datetime.now().astimezone().isoformat(timespec="seconds"),
)
target = self.decisions_dir / f"{pending.meta.session_id}.json"
self._save_atomic(target, record.to_dict())
def load_result(self) -> DecideOutput | None:
"""读取当前会话的决策结果。"""
session_id = self.get_session_id()
if session_id is None:
return None
record_path = self.decisions_dir / f"{session_id}.json"
if not record_path.exists():
return None
data = self._load_json(record_path)
record = DecisionRecord.from_dict(data)
return record.output
def has_pending(self) -> bool:
return self.pending_path.exists()
def has_result(self) -> bool:
session_id = None
try:
session_id = self.get_session_id()
except DecideError:
return False
if not session_id:
return False
record_path = self.decisions_dir / f"{session_id}.json"
return record_path.exists()
def _save_atomic(self, path: Path, data: dict[str, Any]) -> None:
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
tmp_path = path.with_suffix(path.suffix + ".tmp")
try:
tmp_path.write_text(payload, encoding="utf-8")
os.replace(tmp_path, path)
except Exception as exc:
raise DecideError(f"写入 {path.name} 失败: {exc}")
def _load_json(self, path: Path) -> dict[str, Any]:
try:
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
if not isinstance(data, dict):
raise DecideError(f"{path.name} 顶层必须为对象")
return data
except DecideError:
raise
except Exception as exc:
raise DecideError(f"无法解析 {path.name}: {exc}")

View File

@@ -0,0 +1,323 @@
"""decide 模块的数据结构与校验。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from aide.decide.errors import DecideError
@dataclass(frozen=True)
class Location:
file: str
start: int
end: int
@staticmethod
def from_dict(data: Any, path: str) -> "Location":
if not isinstance(data, dict):
raise DecideError(f"{path} 必须为对象")
file = _require_str(data.get("file"), f"{path}.file")
start = _require_int(data.get("start"), f"{path}.start")
end = _require_int(data.get("end"), f"{path}.end")
return Location(file=file, start=start, end=end)
def to_dict(self) -> dict[str, Any]:
return {"file": self.file, "start": self.start, "end": self.end}
@dataclass(frozen=True)
class Option:
value: str
label: str
score: float | None = None
pros: list[str] | None = None
cons: list[str] | None = None
@staticmethod
def from_dict(data: Any, path: str, used_values: set[str]) -> "Option":
if not isinstance(data, dict):
raise DecideError(f"{path} 必须为对象")
raw_value = data.get("value")
value = _require_str(raw_value, f"{path}.value")
if value in used_values:
raise DecideError(f"{path}.value 在当前待定项中必须唯一,重复值: {value}")
used_values.add(value)
label = _require_str(data.get("label"), f"{path}.label")
score = _optional_score(data.get("score"), f"{path}.score")
pros = _optional_str_list(data.get("pros"), f"{path}.pros")
cons = _optional_str_list(data.get("cons"), f"{path}.cons")
return Option(value=value, label=label, score=score, pros=pros, cons=cons)
def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = {"value": self.value, "label": self.label}
if self.score is not None:
data["score"] = self.score
if self.pros is not None:
data["pros"] = self.pros
if self.cons is not None:
data["cons"] = self.cons
return data
@dataclass(frozen=True)
class DecideItem:
id: int
title: str
options: list[Option]
location: Location | None = None
context: str | None = None
recommend: str | None = None
@staticmethod
def from_dict(data: Any, index: int) -> "DecideItem":
path = f"items[{index}]"
if not isinstance(data, dict):
raise DecideError(f"{path} 必须为对象")
item_id = _require_positive_int(data.get("id"), f"{path}.id")
title = _require_str(data.get("title"), f"{path}.title")
raw_options = data.get("options")
if not isinstance(raw_options, list) or not raw_options:
raise DecideError(f"{path}.options 必须为至少 1 个元素的数组")
if len(raw_options) < 2:
raise DecideError(f"{path}.options 至少需要 2 个选项,当前只有 {len(raw_options)}")
options: list[Option] = []
used_values: set[str] = set()
for opt_index, raw_opt in enumerate(raw_options):
options.append(
Option.from_dict(raw_opt, f"{path}.options[{opt_index}]", used_values)
)
recommend = data.get("recommend")
if recommend is not None:
recommend = _require_str(recommend, f"{path}.recommend")
if recommend not in {opt.value for opt in options}:
raise DecideError(f'{path}.recommend 值 "{recommend}" 不在 options 中')
location = None
if "location" in data and data.get("location") is not None:
location = Location.from_dict(data["location"], f"{path}.location")
context = None
if "context" in data and data.get("context") is not None:
context = _require_str(data.get("context"), f"{path}.context", allow_empty=True)
return DecideItem(
id=item_id,
title=title,
options=options,
location=location,
context=context,
recommend=recommend,
)
def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = {
"id": self.id,
"title": self.title,
"options": [opt.to_dict() for opt in self.options],
}
if self.location is not None:
data["location"] = self.location.to_dict()
if self.context is not None:
data["context"] = self.context
if self.recommend is not None:
data["recommend"] = self.recommend
return data
@dataclass(frozen=True)
class MetaInfo:
created_at: str
session_id: str
@staticmethod
def from_dict(data: Any) -> "MetaInfo":
if not isinstance(data, dict):
raise DecideError("_meta 必须为对象")
created_at = _require_str(data.get("created_at"), "_meta.created_at")
session_id = _require_str(data.get("session_id"), "_meta.session_id")
return MetaInfo(created_at=created_at, session_id=session_id)
def to_dict(self) -> dict[str, Any]:
return {"created_at": self.created_at, "session_id": self.session_id}
@dataclass(frozen=True)
class DecideInput:
task: str
source: str
items: list[DecideItem]
meta: MetaInfo | None = None
@staticmethod
def from_dict(data: Any) -> "DecideInput":
if not isinstance(data, dict):
raise DecideError("输入数据必须为对象")
task = _require_str(data.get("task"), "task")
source = _require_str(data.get("source"), "source")
raw_items = data.get("items")
if not isinstance(raw_items, list) or not raw_items:
raise DecideError("items 必须为至少 1 个元素的数组")
items: list[DecideItem] = []
used_ids: set[int] = set()
for idx, raw_item in enumerate(raw_items):
item = DecideItem.from_dict(raw_item, idx)
if item.id in used_ids:
raise DecideError(f"items[{idx}].id 与已有待定项重复: {item.id}")
used_ids.add(item.id)
items.append(item)
meta = None
if "_meta" in data and data.get("_meta") is not None:
meta = MetaInfo.from_dict(data["_meta"])
return DecideInput(task=task, source=source, items=items, meta=meta)
def to_dict(self, include_meta: bool = True) -> dict[str, Any]:
data: dict[str, Any] = {
"task": self.task,
"source": self.source,
"items": [item.to_dict() for item in self.items],
}
if include_meta and self.meta is not None:
data["_meta"] = self.meta.to_dict()
return data
def with_meta(self, meta: MetaInfo) -> "DecideInput":
return DecideInput(
task=self.task,
source=self.source,
items=self.items,
meta=meta,
)
def without_meta(self) -> "DecideInput":
return DecideInput(task=self.task, source=self.source, items=self.items, meta=None)
@dataclass(frozen=True)
class Decision:
id: int
chosen: str
note: str | None = None
@staticmethod
def from_dict(data: Any, index: int) -> "Decision":
path = f"decisions[{index}]"
if not isinstance(data, dict):
raise DecideError(f"{path} 必须为对象")
item_id = _require_positive_int(data.get("id"), f"{path}.id")
chosen = _require_str(data.get("chosen"), f"{path}.chosen")
note = None
if "note" in data and data.get("note") is not None:
note = _require_str(data.get("note"), f"{path}.note", allow_empty=True)
return Decision(id=item_id, chosen=chosen, note=note)
def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = {"id": self.id, "chosen": self.chosen}
if self.note is not None:
data["note"] = self.note
return data
@dataclass(frozen=True)
class DecideOutput:
decisions: list[Decision]
@staticmethod
def from_dict(data: Any) -> "DecideOutput":
if not isinstance(data, dict):
raise DecideError("输出数据必须为对象")
raw_decisions = data.get("decisions")
if not isinstance(raw_decisions, list) or not raw_decisions:
raise DecideError("decisions 必须为至少 1 个元素的数组")
decisions: list[Decision] = []
for idx, raw in enumerate(raw_decisions):
decisions.append(Decision.from_dict(raw, idx))
return DecideOutput(decisions=decisions)
def to_dict(self) -> dict[str, Any]:
return {"decisions": [d.to_dict() for d in self.decisions]}
@dataclass(frozen=True)
class DecisionRecord:
input: DecideInput
output: DecideOutput
completed_at: str
@staticmethod
def from_dict(data: Any) -> "DecisionRecord":
if not isinstance(data, dict):
raise DecideError("决策记录必须为对象")
input_data = data.get("input")
output_data = data.get("output")
completed_at = _require_str(data.get("completed_at"), "completed_at")
if input_data is None:
raise DecideError("决策记录缺少 input")
if output_data is None:
raise DecideError("决策记录缺少 output")
return DecisionRecord(
input=DecideInput.from_dict(input_data),
output=DecideOutput.from_dict(output_data),
completed_at=completed_at,
)
def to_dict(self) -> dict[str, Any]:
return {
"input": self.input.to_dict(include_meta=False),
"output": self.output.to_dict(),
"completed_at": self.completed_at,
}
def _require_str(value: Any, path: str, *, allow_empty: bool = False) -> str:
if not isinstance(value, str):
raise DecideError(f"{path} 必须为字符串")
if not allow_empty and not value.strip():
raise DecideError(f"{path} 不能为空")
return value
def _require_int(value: Any, path: str) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise DecideError(f"{path} 必须为整数")
return value
def _require_positive_int(value: Any, path: str) -> int:
number = _require_int(value, path)
if number <= 0:
raise DecideError(f"{path} 必须为正整数")
return number
def _optional_score(value: Any, path: str) -> float | None:
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise DecideError(f"{path} 必须为数字")
if value < 0 or value > 100:
raise DecideError(f"{path} 必须在 0-100 范围内")
return float(value)
def _optional_str_list(value: Any, path: str) -> list[str] | None:
if value is None:
return None
if not isinstance(value, list):
raise DecideError(f"{path} 必须为字符串数组")
normalized: list[str] = []
for idx, item in enumerate(value):
if not isinstance(item, str):
raise DecideError(f"{path}[{idx}] 必须为字符串")
normalized.append(item)
return normalized

View File

@@ -0,0 +1,313 @@
const AppState = {
task: "",
source: "",
items: [],
decisions: {},
notes: {},
isSubmitting: false,
};
async function init() {
try {
const data = await loadItems();
AppState.task = data.task || "";
AppState.source = data.source || "";
AppState.items = Array.isArray(data.items) ? data.items : [];
renderItems(data);
bindEvents();
} catch (error) {
showError("无法加载待定项数据,请刷新页面重试");
}
}
async function loadItems() {
const response = await fetch("/api/items");
if (!response.ok) {
throw new Error("加载失败");
}
return response.json();
}
function renderItems(data) {
const container = document.getElementById("items-container");
container.innerHTML = "";
document.getElementById("task-name").textContent = data.task || "-";
document.getElementById("task-source").textContent = data.source || "-";
data.items.forEach((item) => {
container.appendChild(renderItemCard(item));
});
updateProgress();
updateSubmitButton();
}
function renderItemCard(item) {
const card = document.createElement("article");
card.className = "item-card";
card.dataset.itemId = String(item.id);
const header = document.createElement("header");
header.className = "item-header";
const title = document.createElement("h2");
title.className = "item-title";
const number = document.createElement("span");
number.className = "item-number";
number.textContent = `${item.id}.`;
const titleText = document.createElement("span");
titleText.textContent = item.title || "待定项";
title.appendChild(number);
title.appendChild(titleText);
const recommend = document.createElement("span");
recommend.className = "recommend-badge";
if (item.recommend) {
recommend.textContent = `推荐: ${item.recommend}`;
} else {
recommend.hidden = true;
}
header.appendChild(title);
header.appendChild(recommend);
card.appendChild(header);
if (item.context) {
const context = document.createElement("div");
context.className = "item-context";
context.textContent = item.context;
card.appendChild(context);
}
if (item.location && item.location.file) {
const location = document.createElement("div");
location.className = "item-location";
location.textContent = `位置: ${item.location.file}:${item.location.start}-${item.location.end}`;
card.appendChild(location);
}
const options = renderOptions(item);
card.appendChild(options);
const noteWrap = document.createElement("div");
noteWrap.className = "item-note";
const noteLabel = document.createElement("label");
noteLabel.setAttribute("for", `note-${item.id}`);
noteLabel.textContent = "备注(可选):";
const textarea = document.createElement("textarea");
textarea.id = `note-${item.id}`;
textarea.placeholder = "添加补充说明...";
noteWrap.appendChild(noteLabel);
noteWrap.appendChild(textarea);
card.appendChild(noteWrap);
return card;
}
function renderOptions(item) {
const optionsWrap = document.createElement("div");
optionsWrap.className = "options-list";
const current = AppState.decisions[item.id];
item.options.forEach((option) => {
const label = document.createElement("label");
label.className = "option-item";
label.dataset.value = option.value;
label.dataset.recommended = option.value === item.recommend ? "true" : "false";
const input = document.createElement("input");
input.type = "radio";
input.name = `item-${item.id}`;
input.value = option.value;
if (current === option.value) {
input.checked = true;
label.classList.add("selected");
}
const content = document.createElement("div");
content.className = "option-content";
const header = document.createElement("div");
header.className = "option-header";
const optLabel = document.createElement("span");
optLabel.className = "option-label";
optLabel.textContent = option.label || option.value;
header.appendChild(optLabel);
if (option.score !== undefined && option.score !== null) {
const score = document.createElement("span");
score.className = "option-score";
score.textContent = `评分: ${option.score}`;
header.appendChild(score);
}
content.appendChild(header);
const hasPros = Array.isArray(option.pros) && option.pros.length > 0;
const hasCons = Array.isArray(option.cons) && option.cons.length > 0;
if (hasPros || hasCons) {
const details = document.createElement("div");
details.className = "option-details";
if (hasPros) {
const pros = document.createElement("div");
pros.className = "option-pros";
pros.innerHTML = `<strong>优点:</strong> ${option.pros.join("")}`;
details.appendChild(pros);
}
if (hasCons) {
const cons = document.createElement("div");
cons.className = "option-cons";
cons.innerHTML = `<strong>缺点:</strong> ${option.cons.join("")}`;
details.appendChild(cons);
}
content.appendChild(details);
}
label.appendChild(input);
label.appendChild(content);
optionsWrap.appendChild(label);
});
return optionsWrap;
}
function bindEvents() {
const container = document.getElementById("items-container");
container.addEventListener("change", (event) => {
const target = event.target;
if (target && target.type === "radio") {
const itemId = parseInt(target.name.replace("item-", ""), 10);
handleOptionSelect(itemId, target.value);
}
});
container.addEventListener("input", (event) => {
const target = event.target;
if (target && target.tagName === "TEXTAREA") {
const itemId = parseInt(target.id.replace("note-", ""), 10);
handleNoteInput(itemId, target.value);
}
});
document.getElementById("submit-btn").addEventListener("click", submitDecisions);
}
function handleOptionSelect(itemId, value) {
AppState.decisions[itemId] = value;
const card = document.querySelector(`.item-card[data-item-id="${itemId}"]`);
if (card) {
const options = card.querySelectorAll(".option-item");
options.forEach((opt) => {
if (opt.dataset.value === value) {
opt.classList.add("selected");
} else {
opt.classList.remove("selected");
}
});
if (AppState.decisions[itemId]) {
card.classList.add("completed");
}
}
updateProgress();
updateSubmitButton();
}
function handleNoteInput(itemId, note) {
AppState.notes[itemId] = note;
}
function updateProgress() {
const total = AppState.items.length;
const completed = Object.keys(AppState.decisions).length;
const text = document.getElementById("progress-text");
text.textContent = `已完成 ${completed}/${total}`;
}
function canSubmit() {
return (
AppState.items.length > 0 &&
Object.keys(AppState.decisions).length === AppState.items.length &&
!AppState.isSubmitting
);
}
function updateSubmitButton() {
const button = document.getElementById("submit-btn");
const allowed = canSubmit();
button.disabled = !allowed;
button.setAttribute("aria-disabled", allowed ? "false" : "true");
button.textContent = AppState.isSubmitting ? "提交中..." : "提交决策";
}
function buildDecisionData() {
const decisions = AppState.items.map((item) => {
const note = AppState.notes[item.id];
const trimmed = typeof note === "string" ? note.trim() : "";
const payload = { id: item.id, chosen: AppState.decisions[item.id] };
if (trimmed) {
payload.note = trimmed;
}
return payload;
});
return { decisions };
}
async function submitDecisions() {
if (!canSubmit()) {
showError("请先完成所有待定项的选择");
return;
}
AppState.isSubmitting = true;
updateSubmitButton();
try {
const response = await fetch("/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildDecisionData()),
});
if (!response.ok) {
let detail = "提交失败";
try {
const error = await response.json();
detail = error.detail || detail;
} catch (e) {
// ignore
}
throw new Error(detail);
}
showSuccess();
} catch (error) {
AppState.isSubmitting = false;
updateSubmitButton();
showError(`提交失败: ${error.message}`);
}
}
function showSuccess() {
const overlay = document.getElementById("success-overlay");
overlay.hidden = false;
AppState.isSubmitting = false;
const button = document.getElementById("submit-btn");
button.disabled = true;
button.setAttribute("aria-disabled", "true");
}
let errorTimer = null;
function showError(message) {
const toast = document.getElementById("error-toast");
const text = document.getElementById("error-message");
text.textContent = message;
toast.hidden = false;
if (errorTimer) {
clearTimeout(errorTimer);
}
errorTimer = setTimeout(() => {
toast.hidden = true;
}, 4000);
}
document.addEventListener("DOMContentLoaded", init);

View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aide 待定项确认</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<header class="header">
<div class="title">
<h1>Aide 待定项确认</h1>
<p class="subtitle">请根据任务信息完成待定项选择</p>
</div>
<div class="task-info">
<p><strong>任务:</strong> <span id="task-name"></span></p>
<p><strong>来源:</strong> <span id="task-source"></span></p>
</div>
</header>
<main class="items-container" id="items-container" aria-label="待定项列表"></main>
<footer class="footer">
<div class="progress">
<span id="progress-text">已完成 0/0 项</span>
</div>
<button class="submit-btn" id="submit-btn" disabled aria-disabled="true" aria-describedby="submit-hint">
提交决策
</button>
<span id="submit-hint" class="sr-only">请先完成所有待定项的选择</span>
</footer>
</div>
<div class="success-overlay" id="success-overlay" hidden>
<div class="success-message">
<h2>决策已提交</h2>
<p>您可以关闭此页面</p>
</div>
</div>
<div class="error-toast" id="error-toast" hidden role="alert">
<span class="error-icon"></span>
<span class="error-message" id="error-message"></span>
</div>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,336 @@
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-success: #16a34a;
--color-warning: #ca8a04;
--color-error: #dc2626;
--color-text: #1f2937;
--color-text-secondary: #6b7280;
--color-border: #e5e7eb;
--color-background: #f9fafb;
--color-card: #ffffff;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-sm: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 24px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-family);
background: radial-gradient(circle at 20% 20%, #f1f5f9, #f9fafb 50%);
color: var(--color-text);
}
.container {
max-width: 960px;
margin: 0 auto;
padding: var(--spacing-xl) var(--spacing-lg);
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
}
.title h1 {
margin: 0 0 var(--spacing-xs);
font-size: 28px;
}
.title .subtitle {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-md);
}
.task-info p {
margin: var(--spacing-xs) 0;
color: var(--color-text-secondary);
}
.items-container {
margin: var(--spacing-xl) 0;
display: grid;
gap: var(--spacing-lg);
}
.item-card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.item-card.completed {
border-color: var(--color-primary);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.08);
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md);
}
.item-title {
margin: 0;
font-size: var(--font-size-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.item-number {
color: var(--color-primary);
font-weight: 700;
}
.recommend-badge {
font-size: var(--font-size-sm);
color: var(--color-primary);
background: rgba(37, 99, 235, 0.1);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-md);
}
.item-context,
.item-location {
margin-top: var(--spacing-sm);
color: var(--color-text-secondary);
line-height: 1.6;
}
.options-list {
margin-top: var(--spacing-md);
display: grid;
gap: var(--spacing-sm);
}
.option-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-md);
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.option-item input {
margin-top: 4px;
}
.option-item.selected {
border-color: var(--color-primary);
background: rgba(37, 99, 235, 0.05);
}
.option-item[data-recommended="true"] {
border-style: dashed;
}
.option-content {
width: 100%;
}
.option-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: var(--color-text);
}
.option-score {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.option-details {
display: flex;
gap: var(--spacing-lg);
margin-top: var(--spacing-xs);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.option-details strong {
color: var(--color-text);
font-weight: 600;
}
.item-note {
margin-top: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.item-note label {
font-weight: 600;
color: var(--color-text);
}
.item-note textarea {
width: 100%;
min-height: 80px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-sm);
font-size: var(--font-size-md);
resize: vertical;
font-family: var(--font-family);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-md) var(--spacing-lg);
box-shadow: var(--shadow-sm);
}
.progress {
color: var(--color-text-secondary);
font-weight: 600;
}
.submit-btn {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-md);
padding: var(--spacing-sm) var(--spacing-lg);
font-size: var(--font-size-md);
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
min-width: 140px;
}
.submit-btn:hover:not(:disabled) {
background: var(--color-primary-hover);
box-shadow: var(--shadow-md);
}
.submit-btn:disabled,
.submit-btn[aria-disabled="true"] {
background: var(--color-border);
color: var(--color-text-secondary);
cursor: not-allowed;
box-shadow: none;
}
.success-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.success-message {
background: var(--color-card);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
text-align: center;
}
.success-message h2 {
margin: 0 0 var(--spacing-sm);
color: var(--color-success);
}
.error-toast {
position: fixed;
bottom: var(--spacing-lg);
right: var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
background: #fff;
border: 1px solid var(--color-error);
color: var(--color-error);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
min-width: 220px;
}
.error-icon {
font-weight: 700;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 640px) {
.container {
padding: var(--spacing-lg);
}
.header {
flex-direction: column;
gap: var(--spacing-md);
}
.option-details {
flex-direction: column;
}
.footer {
flex-direction: column;
align-items: flex-start;
}
.submit-btn {
width: 100%;
text-align: center;
}
}

View File

@@ -9,6 +9,7 @@ from typing import Any
from aide.core import output from aide.core import output
from aide.core.config import ConfigManager from aide.core.config import ConfigManager
from aide.decide import cmd_decide
from aide.env.manager import EnvManager from aide.env.manager import EnvManager
from aide.flow.tracker import FlowTracker from aide.flow.tracker import FlowTracker
@@ -129,6 +130,11 @@ def build_parser() -> argparse.ArgumentParser:
flow_parser.set_defaults(func=handle_flow_help) flow_parser.set_defaults(func=handle_flow_help)
# aide decide
decide_parser = subparsers.add_parser("decide", help="待定项确认与决策记录")
decide_parser.add_argument("data", help="待定项 JSON 数据或 result")
decide_parser.set_defaults(func=handle_decide)
parser.add_argument("--version", action="version", version="aide dev") parser.add_argument("--version", action="version", version="aide dev")
return parser return parser
@@ -282,6 +288,10 @@ def handle_flow_error(args: argparse.Namespace) -> bool:
return tracker.error(args.description) return tracker.error(args.description)
def handle_decide(args: argparse.Namespace) -> bool:
return cmd_decide(args)
def _parse_value(raw: str) -> Any: def _parse_value(raw: str) -> Any:
lowered = raw.lower() lowered = raw.lower()
if lowered in {"true", "false"}: if lowered in {"true", "false"}:

View File

@@ -52,6 +52,11 @@ path = "requirements.txt"
# flow: 流程配置 # flow: 流程配置
[flow] [flow]
phases = ["task-optimize", "flow-design", "impl", "verify", "docs", "finish"] phases = ["task-optimize", "flow-design", "impl", "verify", "docs", "finish"]
# decide: 待定项确认服务配置
[decide]
port = 3721
timeout = 0
``` ```
--- ---
@@ -156,6 +161,17 @@ manager = "pnpm"
- `aide flow` 校验环节跳转合法性 - `aide flow` 校验环节跳转合法性
- 定义有效的环节名称 - 定义有效的环节名称
### 4.5 [decide] 待定项确认配置
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `port` | int | `3721` | Web 服务起始端口,端口被占用时向后探测最多 10 次 |
| `timeout` | int | `0` | 服务超时时间0 表示不启用超时 |
**使用场景**
- `aide decide '<json>'` 读取 `port` 作为起始端口
- `aide decide '<json>'` 读取 `timeout` 控制服务最长等待时间
--- ---
## 五、配置读写接口 ## 五、配置读写接口

View File

@@ -1,8 +0,0 @@
1. 首先阅读 @statements/optimize.md还有 @README.md 并根据其指引了解跟aide decide有关的所有信息
2. 对aide decide开始详细设计我将在完成设计后将所有设计文档打包交给其他开发人员进行开发
- 设计文档中要有一个用于指导接手人员的入口文档,其中要说明:需要了解哪些信息、需要做什么事
对开发文档完善程度的预期是可以仅靠整套文档和可能含有的其他附属文件的内容我将会打包aide-program目录以此为确定目标和完整指导aide-program/docs/ 目录下的相关文档开始目标程序的实际开发工作直到构建产出程序和通过测试运行运行aide decide -h 及其一系列命令与参数)
切记非常重要开发文档中不允许给出代码片段、实现代码最多只能出现类型与数据结构伪代码原型、方法签名、业务逻辑流程的plantuml流程图、API约定