✨ feat: 初步实现aide decide
This commit is contained in:
8
.cache/p2/1.md
Normal file
8
.cache/p2/1.md
Normal 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约定
|
||||||
|
|
||||||
@@ -1,5 +1 @@
|
|||||||
"""
|
"""Aide 命令行工具包:环境检测、流程追踪与待定项确认。"""
|
||||||
Aide 命令行工具包。
|
|
||||||
|
|
||||||
当前版本聚焦基础环境检测与配置管理,flow/decide 功能尚未实现。
|
|
||||||
"""
|
|
||||||
|
|||||||
@@ -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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
9
aide-program/aide/decide/__init__.py
Normal file
9
aide-program/aide/decide/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
99
aide-program/aide/decide/cli.py
Normal file
99
aide-program/aide/decide/cli.py
Normal 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")
|
||||||
6
aide-program/aide/decide/errors.py
Normal file
6
aide-program/aide/decide/errors.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""decide 模块内部错误类型。"""
|
||||||
|
|
||||||
|
|
||||||
|
class DecideError(RuntimeError):
|
||||||
|
"""用于将内部错误转换为统一的 CLI 输出。"""
|
||||||
|
|
||||||
154
aide-program/aide/decide/handlers.py
Normal file
154
aide-program/aide/decide/handlers.py
Normal 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
|
||||||
|
|
||||||
202
aide-program/aide/decide/server.py
Normal file
202
aide-program/aide/decide/server.py
Normal 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
|
||||||
117
aide-program/aide/decide/storage.py
Normal file
117
aide-program/aide/decide/storage.py
Normal 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}")
|
||||||
|
|
||||||
323
aide-program/aide/decide/types.py
Normal file
323
aide-program/aide/decide/types.py
Normal 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
|
||||||
|
|
||||||
313
aide-program/aide/decide/web/app.js
Normal file
313
aide-program/aide/decide/web/app.js
Normal 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);
|
||||||
49
aide-program/aide/decide/web/index.html
Normal file
49
aide-program/aide/decide/web/index.html
Normal 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>
|
||||||
336
aide-program/aide/decide/web/style.css
Normal file
336
aide-program/aide/decide/web/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}:
|
||||||
|
|||||||
@@ -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` 控制服务最长等待时间
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、配置读写接口
|
## 五、配置读写接口
|
||||||
|
|||||||
@@ -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约定
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user