265 lines
9.6 KiB
Python
265 lines
9.6 KiB
Python
"""CLI 入口: 简单 REPL。
|
|
|
|
用法:
|
|
python cli.py chat # 新建一个 task
|
|
python cli.py chat --mode coding --desc "修一处 bug" # 带元数据建任务
|
|
python cli.py chat --resume last # 恢复最近一个 task
|
|
python cli.py chat --resume 20260506_141523 # 显式 task_id
|
|
python cli.py chat --model deepseek_v4.pro
|
|
python cli.py tasks # 列出 task
|
|
python cli.py probe # 实测对账 yaml 声称的能力
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
|
|
import click
|
|
from rich.console import Console
|
|
from rich.prompt import Prompt
|
|
from rich.table import Table
|
|
|
|
from core.task import TaskState
|
|
from main import (
|
|
ROOT,
|
|
build_agent,
|
|
load_config,
|
|
resolve_workspace,
|
|
sync_task_tokens,
|
|
tasks_dir,
|
|
)
|
|
|
|
|
|
@click.group()
|
|
def cli() -> None:
|
|
"""zcbot - 个人任务 agent"""
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
|
|
@click.option("--workspace", default=None, help="工作目录(存 tasks/ 和 sessions/)")
|
|
@click.option("--resume", default=None, help="恢复 task: 'last' 或 task_id")
|
|
@click.option("--mode", default="", help="任务模式标签(coding/ppt/proposal/...自由形式)")
|
|
@click.option("--desc", default="", help="一句话任务描述,便于 tasks 列表识别")
|
|
def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
|
|
"""启动交互式 REPL。每次启动默认开新 task,用 --resume 接老的。"""
|
|
console = Console()
|
|
try:
|
|
agent, session, sid, task_state, task_dir = build_agent(
|
|
model_name=model,
|
|
workspace=workspace,
|
|
console=console,
|
|
session_id=resume,
|
|
resume=bool(resume),
|
|
mode=mode,
|
|
description=desc,
|
|
)
|
|
except Exception as e:
|
|
console.print(f"[red]启动失败:[/red] {type(e).__name__}: {e}")
|
|
sys.exit(1)
|
|
|
|
if resume:
|
|
console.print(
|
|
f"[green]恢复 task[/green] [bold]{sid}[/bold] ({len(session.messages)} 条消息) "
|
|
f"model: [bold]{agent.caps.model_id}[/bold]"
|
|
)
|
|
else:
|
|
meta_tail = ""
|
|
if task_state.mode or task_state.description:
|
|
meta_tail = f" mode={task_state.mode!r} desc={task_state.description!r}"
|
|
console.print(
|
|
f"[green]新 task[/green] [bold]{sid}[/bold] "
|
|
f"model: [bold]{agent.caps.model_id}[/bold]{meta_tail}"
|
|
)
|
|
console.print(
|
|
"[dim]/exit 退出 /reset 清空对话(保留 task) /new 开新 task /id /status 查看 "
|
|
"/done /abandon 改状态 /desc <文本> 设描述[/dim]\n"
|
|
)
|
|
|
|
while True:
|
|
try:
|
|
user_input = Prompt.ask("[bold blue]you[/bold blue]")
|
|
except (EOFError, KeyboardInterrupt):
|
|
console.print("\n[dim]bye[/dim]")
|
|
break
|
|
|
|
cmd = user_input.strip()
|
|
if cmd in ("/exit", "/quit"):
|
|
break
|
|
if cmd == "/reset":
|
|
session.reset(keep_system=True)
|
|
console.print("[dim]当前 task 对话已重置(保留 system 和 state)[/dim]")
|
|
continue
|
|
if cmd == "/new":
|
|
try:
|
|
agent, session, sid, task_state, task_dir = build_agent(
|
|
model_name=model, workspace=workspace, console=console,
|
|
mode=mode, description=desc,
|
|
)
|
|
except Exception as e:
|
|
console.print(f"[red]新建失败:[/red] {type(e).__name__}: {e}")
|
|
continue
|
|
console.print(f"[green]新 task[/green] [bold]{sid}[/bold]")
|
|
continue
|
|
if cmd == "/id":
|
|
cwd_disp = session.meta.get("cwd", "?")
|
|
model_disp = session.meta.get("model", agent.caps.model_id)
|
|
console.print(f"[dim]task: {sid} model: {model_disp} cwd: {cwd_disp}[/dim]")
|
|
continue
|
|
if cmd == "/status":
|
|
console.print(
|
|
f"[dim]task {task_state.task_id} status={task_state.status} "
|
|
f"mode={task_state.mode!r} desc={task_state.description!r}\n"
|
|
f" model={task_state.model} tokens={task_state.tokens_total} "
|
|
f"(p={task_state.tokens_prompt}/c={task_state.tokens_completion}) "
|
|
f"created={task_state.created_at} updated={task_state.updated_at}[/dim]"
|
|
)
|
|
continue
|
|
if cmd == "/done":
|
|
task_state.status = "completed"
|
|
task_state.save(task_dir)
|
|
console.print(f"[green]task {sid} marked completed[/green]")
|
|
break
|
|
if cmd == "/abandon":
|
|
task_state.status = "abandoned"
|
|
task_state.save(task_dir)
|
|
console.print(f"[yellow]task {sid} marked abandoned[/yellow]")
|
|
break
|
|
if cmd.startswith("/desc"):
|
|
new_desc = cmd[len("/desc"):].strip()
|
|
task_state.description = new_desc
|
|
task_state.save(task_dir)
|
|
console.print(f"[dim]description set: {new_desc!r}[/dim]")
|
|
continue
|
|
if not cmd:
|
|
continue
|
|
|
|
try:
|
|
agent.run(user_input)
|
|
except KeyboardInterrupt:
|
|
console.print("\n[yellow]已中断本轮。下一条输入会继续这个 task。[/yellow]")
|
|
except Exception as e:
|
|
console.print(f"[red]运行错误:[/red] {type(e).__name__}: {e}")
|
|
finally:
|
|
sync_task_tokens(task_state, task_dir, agent.llm)
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--workspace", default=None, help="工作目录")
|
|
@click.option("--limit", default=20, help="显示最近 N 个")
|
|
@click.option("--status", default=None, help="只看某状态: active / completed / abandoned")
|
|
def tasks(workspace: str, limit: int, status: str) -> None:
|
|
"""列出已有 task(新格式,workspace/tasks/<id>/state.json)。"""
|
|
cfg = load_config()
|
|
ws = resolve_workspace(workspace, cfg)
|
|
tdir = tasks_dir(ws)
|
|
|
|
rows = [] # (mtime, task_id, status, mode, model, tokens, n_msgs, desc)
|
|
for d in tdir.iterdir():
|
|
if not d.is_dir():
|
|
continue
|
|
msg_path = d / "messages.json"
|
|
if not msg_path.exists():
|
|
continue
|
|
st = TaskState.load(d)
|
|
if st is None:
|
|
continue
|
|
if status and st.status != status:
|
|
continue
|
|
try:
|
|
data = json.loads(msg_path.read_text(encoding="utf-8"))
|
|
n = len(data.get("messages", []))
|
|
except Exception:
|
|
n = -1
|
|
rows.append((
|
|
msg_path.stat().st_mtime, st.task_id, st.status, st.mode,
|
|
st.model_profile or st.model, st.tokens_total, n, st.description,
|
|
))
|
|
rows.sort(reverse=True)
|
|
rows = rows[:limit]
|
|
|
|
if not rows:
|
|
click.echo(f"(no tasks in {tdir})")
|
|
return
|
|
tbl = Table(show_lines=False)
|
|
tbl.add_column("task id", style="bold")
|
|
tbl.add_column("status")
|
|
tbl.add_column("mode")
|
|
tbl.add_column("model")
|
|
tbl.add_column("msgs", justify="right")
|
|
tbl.add_column("tokens", justify="right")
|
|
tbl.add_column("desc")
|
|
sc = {"active": "cyan", "completed": "green", "abandoned": "dim"}
|
|
for _, tid, st, mode, model, tok, n, desc in rows:
|
|
c = sc.get(st, "white")
|
|
d_show = desc if len(desc) <= 50 else desc[:47] + "..."
|
|
tbl.add_row(tid, f"[{c}]{st}[/{c}]", mode, model, str(n), str(tok), d_show)
|
|
Console().print(tbl)
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
|
|
@click.option("--long-context", is_flag=True, help="加跑 needle-in-haystack(费 token,默认关)")
|
|
def probe(model: str, long_context: bool) -> None:
|
|
"""实测对账模型 yaml 声称的能力。会调用 LLM,有 API 开销。"""
|
|
from core.capabilities import ModelCapabilities
|
|
from core.llm import LLM
|
|
from core.probe import probe_capabilities
|
|
|
|
cfg = load_config()
|
|
name = model or cfg["default_model"]
|
|
|
|
console = Console()
|
|
try:
|
|
caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"])
|
|
except Exception as e:
|
|
console.print(f"[red]档案加载失败:[/red] {type(e).__name__}: {e}")
|
|
sys.exit(1)
|
|
|
|
console.print(
|
|
f"[bold]probing[/bold] [cyan]{caps.model_id}[/cyan] (profile: {name}) "
|
|
f"[dim]long-context={long_context}[/dim]\n"
|
|
)
|
|
|
|
try:
|
|
llm = LLM(caps)
|
|
except Exception as e:
|
|
console.print(f"[red]LLM 构造失败:[/red] {type(e).__name__}: {e}")
|
|
sys.exit(1)
|
|
|
|
with console.status("[dim]running probes...[/dim]", spinner="dots"):
|
|
report = probe_capabilities(caps, llm, include_long_context=long_context)
|
|
|
|
tbl = Table(show_lines=False)
|
|
tbl.add_column("capability", style="bold")
|
|
tbl.add_column("declared")
|
|
tbl.add_column("observed")
|
|
tbl.add_column("status")
|
|
tbl.add_column("detail")
|
|
color = {"ok": "green", "mismatch": "yellow", "error": "red", "skip": "dim"}
|
|
for r in report.results:
|
|
c = color.get(r.status, "white")
|
|
tbl.add_row(
|
|
r.name,
|
|
str(r.declared),
|
|
str(r.observed),
|
|
f"[{c}]{r.status}[/{c}]",
|
|
r.detail,
|
|
)
|
|
console.print(tbl)
|
|
|
|
if report.has_mismatch:
|
|
console.print(
|
|
"\n[yellow]存在能力对账差异 —— 看 detail,必要时改 "
|
|
f"config/models/{caps.family}.yaml[/yellow]"
|
|
)
|
|
sys.exit(2)
|
|
if any(r.status == "error" for r in report.results):
|
|
console.print("\n[red]部分探测出错(见 detail)[/red]")
|
|
sys.exit(3)
|
|
console.print("\n[green]全部能力声明与实测一致。[/green]")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli()
|