commit b08c7ddce7cb3df4f9b301bef45603f660eb95af Author: KINGMAKER\67483 <674835057@qq.com> Date: Mon Aug 31 20:26:59 2026 +0800 初始化 G-CODE 仓库,纳入源码、测试与项目资料。 Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfce0eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.gcode/ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +dist/ +build/ +.env +.pytest_cache/ +.mypy_cache/ +.idea/ +*.db +*.db-journal +*.db-wal +*.db-shm diff --git a/README.md b/README.md new file mode 100644 index 0000000..15baea1 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# G-CODE + +Windows 上的个人 Coding Agent。你在终端里下任务,模型按 ReAct 循环读文件、改文件、跑命令;需要浏览器时走 Playwright MCP。主界面是自研 Textual TUI;经典 CMD 无法承载 TUI 中文输入时,自动回退 REPL。 + +不是 IDE 插件,不是 Web 对话页,也不是 QozeCode 换皮。 + +## 数据流 + +```text +gcode + ├─ 探测终端(方案 C) + │ WT / VS Code / Cursor → TUI + │ conhost / 经典 cmd → 提示用 Windows Terminal,进入 REPL + └─ Runtime + UI 收集输入(含 /edit、HITL y/N) + → Agent.stream_turn / invoke_turn + START → llm_call ⇄ tool_node → END + → 静态 System prompt + 动态 cwd/目录树 + → 工具:read_file / list_dir / grep / replace_in_file / execute_command + → 写文件出 diff、跑命令出命令,用户确认后才执行 + → 会话:/.gcode/checkpoints.db + thread_id +``` + +TUI 和 REPL 都只消费 `Agent.stream_turn` 产生的事件。工具不能反向 import 图模块。 + +## 安装 + +Python 3.10+。 + +```text +cd D:\ideaProjects\gcode +python -m pip install -e ".[dev]" +``` + +首次运行会在 `%APPDATA%\gcode\` 写入 `models.yaml`。把对应环境变量配好,例如: + +```text +setx GCODE_API_KEY "你的 key" +setx DEEPSEEK_API_KEY "你的 key" +``` + +建议在虚拟环境中安装,避免和其它项目的 LangChain 版本互相覆盖。 + +密钥只放用户目录或环境变量,不要提交到 git。模板见仓库里的 `models.yaml.example`。 + +## 使用 + +若 `gcode` 不在 PATH 上,用 `python -m gcode`: + +```text +python -m gcode --list-models +python -m gcode --model agicto/deepseek-v4-flash +python -m gcode --ui repl +python -m gcode --session default +``` + +- `--ui auto`(默认):能跑 TUI 就开 TUI;conhost 拒绝 TUI 并回退 REPL +- `--ui tui`:在 conhost 上会非零退出,不会黑屏假死 +- `--ui repl`:任意终端熟模式,IME 归系统 + +斜杠命令(不进模型):`/help` `/edit` `/clear` `/model` `/quit`。 + +`/edit` 打开 `%EDITOR%` 或记事本,保存后当作本轮用户输入。 + +## 配置根 + +| 位置 | 内容 | +|------|------| +| `%APPDATA%\gcode\` | `models.yaml`、`mcp.json` | +| `/.gcode/` | `checkpoints.db`、`session.json` | + +不要再混用 `~\.gcode`。 + +加一家 OpenAI 兼容模型:只改 `models.yaml`,不改 Python。一期只实现 `openai_compatible`。 + +## 人在回路 + +| 动作 | 展示 | 拒绝时 | +|------|------|--------| +| `replace_in_file` | unified diff | 工具返回「用户拒绝本次写入」,模型改方案 | +| `execute_command` | 完整命令 + 工作目录 | 工具返回「用户拒绝执行该命令」 | + +读文件、列目录不确认。Windows 下命令默认走 PowerShell;输出先按 UTF-8 解码,失败再 GBK。超时用 `taskkill /T` 杀进程树。 + +## 浏览器 + +只接 `@playwright/mcp`(`npx -y @playwright/mcp@latest`)。没有 Node.js / npx 时提示不可用,没有 Python Playwright 备选。启动加 `--skip-mcp` 可关掉。 + +## 测试 + +```text +python -m pytest +``` + +核心图不依赖 Textual,mock LLM 的冒烟测试在 `tests/test_graph_smoke.py`。 + +## 明确不做 + +Skills、Subagent、Plan 模式、语音、Island、其它 MCP 市场、Vertex / Anthropic / Bedrock 原生协议。文档以代码为准。 diff --git a/models.yaml.example b/models.yaml.example new file mode 100644 index 0000000..5e19125 --- /dev/null +++ b/models.yaml.example @@ -0,0 +1,28 @@ +# G-CODE 模型注册表示例。复制到 %APPDATA%\gcode\models.yaml +# 密钥只放环境变量,不要把 Key 写进本文件。 +default: agicto/deepseek-v4-flash + +providers: + deepseek: + api: openai_compatible + base_url: https://api.deepseek.com/v1 + api_key_env: DEEPSEEK_API_KEY + models: + - id: deepseek-chat + vision: false + reasoning: false + - id: deepseek-reasoner + vision: false + reasoning: true + + agicto: + api: openai_compatible + base_url: https://api.agicto.cn/v1 + api_key_env: GCODE_API_KEY + models: + - id: deepseek-v4-flash + vision: false + reasoning: false + - id: deepseek-v4-pro + vision: false + reasoning: true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fd1ea39 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "gcode-agent" +version = "0.1.0" +description = "G-CODE: Windows-first personal coding agent (ReAct + TUI / REPL)" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "G-CODE" }] + +dependencies = [ + "langgraph>=0.2.0", + "langchain-core>=0.3.0", + "langchain-openai>=0.2.0", + "langgraph-checkpoint-sqlite>=2.0.0", + "aiosqlite>=0.20.0", + "pyyaml>=6.0", + "textual>=0.80.0", + "langchain-mcp-adapters>=0.1.0", + "mcp>=1.0.0,<2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24.0", +] + +[project.scripts] +gcode = "gcode.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/src/gcode/__init__.py b/src/gcode/__init__.py new file mode 100644 index 0000000..af9519f --- /dev/null +++ b/src/gcode/__init__.py @@ -0,0 +1,3 @@ +"""G-CODE: Windows-first personal coding agent.""" + +__version__ = "0.1.0" diff --git a/src/gcode/__main__.py b/src/gcode/__main__.py new file mode 100644 index 0000000..087d646 --- /dev/null +++ b/src/gcode/__main__.py @@ -0,0 +1,4 @@ +from gcode.cli import main + +if __name__ == "__main__": + main() diff --git a/src/gcode/cli.py b/src/gcode/cli.py new file mode 100644 index 0000000..6680e49 --- /dev/null +++ b/src/gcode/cli.py @@ -0,0 +1,164 @@ +"""CLI entry: parse args, detect terminal, bootstrap agent, run TUI or REPL.""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from gcode import __version__ +from gcode.config import ensure_home, ensure_project_dir +from gcode.graph import Agent +from gcode.mcp.client import PlaywrightMCP +from gcode.models.openai_compatible import create_chat_model +from gcode.models.registry import ModelSpec, load_registry, resolve_model +from gcode.safety import SafetyGate +from gcode.session import Session +from gcode.tools.fs import make_fs_tools +from gcode.tools.shell import make_shell_tools +from gcode.ui.detect import detect_terminal, resolve_ui_mode +from gcode.windows import configure_stdio + + +@dataclass +class Runtime: + cwd: Path + spec: ModelSpec + safety: SafetyGate + session: Session + mcp: PlaywrightMCP + tools: list[Any] + agent: Agent + + def switch_model(self, query: str) -> None: + spec = resolve_model(query) + llm = create_chat_model(spec) + self.spec = spec + self.agent = Agent( + llm, + self.tools, + session=self.session, + model_id=spec.qualified_id, + has_browser=self.mcp.available, + cwd=self.cwd, + ) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="gcode", + description="G-CODE:Windows 上的个人 Coding Agent", + ) + p.add_argument("--model", help="模型 id,或 provider/id(见 %%APPDATA%%\\gcode\\models.yaml)") + p.add_argument("--session", help="会话 thread_id,默认读取 /.gcode/session.json") + p.add_argument( + "--ui", + choices=("auto", "tui", "repl"), + default="auto", + help="auto=探测终端;conhost 自动回退 REPL", + ) + p.add_argument("--list-models", action="store_true", help="列出 models.yaml 中的模型后退出") + p.add_argument("--skip-mcp", action="store_true", help="不启动 Playwright MCP") + p.add_argument("--version", action="version", version=f"G-CODE {__version__}") + return p + + +async def bootstrap(args: argparse.Namespace) -> Runtime: + cwd = Path.cwd() + ensure_home() + ensure_project_dir(cwd) + spec = resolve_model(args.model) + llm = create_chat_model(spec) + safety = SafetyGate() + tools: list[Any] = [] + tools.extend(make_fs_tools(safety, cwd)) + tools.extend(make_shell_tools(safety, cwd)) + + mcp = PlaywrightMCP() + if not args.skip_mcp: + await mcp.start() + tools.extend(mcp.tools) + + session = Session(thread_id=args.session, cwd=cwd) + await session.start() + agent = Agent( + llm, + tools, + session=session, + model_id=spec.qualified_id, + has_browser=mcp.available, + cwd=cwd, + ) + return Runtime( + cwd=cwd, + spec=spec, + safety=safety, + session=session, + mcp=mcp, + tools=tools, + agent=agent, + ) + + +async def async_main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.list_models: + registry = load_registry() + if not registry.models: + print("没有已注册的 openai_compatible 模型。") + return 1 + print(f"default: {registry.default}") + for spec in registry.models: + flags = [] + if spec.reasoning: + flags.append("reasoning") + if spec.vision: + flags.append("vision") + extra = f" ({', '.join(flags)})" if flags else "" + print(f" {spec.qualified_id}{extra}") + return 0 + + info = detect_terminal() + mode, warning = resolve_ui_mode(args.ui, info) + if mode == "refuse": + print(warning, file=sys.stderr) + return 2 + if warning: + print(warning) + + rt: Runtime | None = None + try: + rt = await bootstrap(args) + if mode == "tui": + from gcode.ui.tui.app import GCodeApp + + app = GCodeApp(rt) + await app.run_async() + else: + from gcode.ui.repl import run_repl + + await run_repl(rt, banner=warning if args.ui == "repl" else "") + return 0 + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 + finally: + if rt is not None: + await rt.session.close() + await rt.mcp.close() + + +def main() -> None: + configure_stdio() + raise SystemExit(asyncio.run(async_main())) + + +if __name__ == "__main__": + main() diff --git a/src/gcode/config.py b/src/gcode/config.py new file mode 100644 index 0000000..1896db1 --- /dev/null +++ b/src/gcode/config.py @@ -0,0 +1,52 @@ +"""Single home directory: %APPDATA%\\gcode on Windows, plus /.gcode for project data.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def get_home_dir() -> Path: + """User-level config root. Windows uses %APPDATA%\\gcode only — never ~/.gcode.""" + if os.name == "nt": + appdata = os.environ.get("APPDATA") + if not appdata: + raise RuntimeError("Windows 上未设置 APPDATA,无法确定 G-CODE 用户目录。") + return Path(appdata) / "gcode" + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg: + return Path(xdg) / "gcode" + return Path.home() / ".config" / "gcode" + + +def get_project_dir(cwd: Path | None = None) -> Path: + """Project-level data: checkpoints, debug logs. Lives next to the working tree.""" + return (cwd or Path.cwd()) / ".gcode" + + +def ensure_home() -> Path: + home = get_home_dir() + home.mkdir(parents=True, exist_ok=True) + return home + + +def ensure_project_dir(cwd: Path | None = None) -> Path: + project = get_project_dir(cwd) + project.mkdir(parents=True, exist_ok=True) + return project + + +def session_file(cwd: Path | None = None) -> Path: + return get_project_dir(cwd) / "session.json" + + +def checkpoints_db(cwd: Path | None = None) -> Path: + return get_project_dir(cwd) / "checkpoints.db" + + +def models_yaml_path() -> Path: + return get_home_dir() / "models.yaml" + + +def mcp_config_path() -> Path: + return get_home_dir() / "mcp.json" diff --git a/src/gcode/events.py b/src/gcode/events.py new file mode 100644 index 0000000..305c0c6 --- /dev/null +++ b/src/gcode/events.py @@ -0,0 +1,16 @@ +"""UI-facing stream events. TUI and REPL both consume these; they never import LangGraph internals.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +EventType = Literal["thinking", "text", "tool_start", "tool_end", "done", "error"] + + +@dataclass +class AgentEvent: + type: EventType + text: str = "" + name: str = "" + extra: dict[str, Any] = field(default_factory=dict) diff --git a/src/gcode/graph.py b/src/gcode/graph.py new file mode 100644 index 0000000..98c9c16 --- /dev/null +++ b/src/gcode/graph.py @@ -0,0 +1,264 @@ +"""LangGraph ReAct heart: START → llm_call ⇄ tool_node → END. No global llm/agent.""" + +from __future__ import annotations + +import operator +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any, Literal + +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, SystemMessage, ToolMessage +from langgraph.graph import END, START, StateGraph +from typing_extensions import Annotated, TypedDict + +from gcode.events import AgentEvent +from gcode.models.openai_compatible import extract_reasoning, extract_text +from gcode.prompt import dynamic_context, static_system_prompt +from gcode.session import Session + +MAX_LLM_CALLS = 40 + + +class MessagesState(TypedDict): + messages: Annotated[list[AnyMessage], operator.add] + llm_calls: int + + +class Agent: + def __init__( + self, + model: Any, + tools: list[Any], + *, + session: Session | None = None, + model_id: str = "", + has_browser: bool = False, + cwd: Path | None = None, + ) -> None: + self._tools = {t.name: t for t in tools} + self._model = model.bind_tools(tools) + self._session = session + self._model_id = model_id + self._has_browser = has_browser + self._cwd = cwd or Path.cwd() + self._static = static_system_prompt(has_browser=has_browser) + checkpointer = session.checkpointer if session else None + self._graph = self._build(checkpointer) + + def _build(self, checkpointer: Any) -> Any: + builder = StateGraph(MessagesState) + builder.add_node("llm_call", self._llm_call) + builder.add_node("tool_node", self._tool_node) + builder.add_edge(START, "llm_call") + builder.add_conditional_edges( + "llm_call", + self._should_continue, + {"tool_node": "tool_node", END: END}, + ) + builder.add_edge("tool_node", "llm_call") + return builder.compile(checkpointer=checkpointer) + + def _graph_config(self) -> dict[str, Any] | None: + if self._session and self._session.checkpointer: + return self._session.config() + return None + + async def invoke_turn(self, user_text: str) -> list[AnyMessage]: + payload: MessagesState = { + "messages": [HumanMessage(content=user_text)], + "llm_calls": 0, + } + config = self._graph_config() + result = await self._graph.ainvoke(payload, config=config) + return list(result["messages"]) + + async def stream_turn(self, user_text: str) -> AsyncIterator[AgentEvent]: + payload: MessagesState = { + "messages": [HumanMessage(content=user_text)], + "llm_calls": 0, + } + config = self._graph_config() + try: + async for ev in self._graph.astream_events(payload, config=config, version="v2"): + mapped = _map_stream_event(ev) + if mapped is not None: + yield mapped + yield AgentEvent(type="done") + except Exception as exc: + yield AgentEvent(type="error", text=f"{type(exc).__name__}: {exc}") + + async def _llm_call(self, state: MessagesState) -> dict[str, Any]: + calls = int(state.get("llm_calls") or 0) + if calls >= MAX_LLM_CALLS: + return { + "messages": [ + AIMessage(content="已达到本轮最大循环次数。请把任务拆小,或在下一轮继续。") + ], + "llm_calls": calls, + } + + dyn = dynamic_context(self._cwd, self._model_id) + messages = _assemble_messages(self._static, dyn, list(state.get("messages") or [])) + messages = _strip_images(messages) + messages = _repair_incomplete_tool_calls(messages) + + try: + response = await self._model.ainvoke(messages) + return {"messages": [response], "llm_calls": calls + 1} + except Exception as exc: + err = str(exc) + if "tool_calls" in err and "tool_call_id" in err: + try: + repaired = _repair_incomplete_tool_calls(messages) + response = await self._model.ainvoke(repaired) + return {"messages": [response], "llm_calls": calls + 1} + except Exception: + pass + friendly = f"模型请求失败: {type(exc).__name__}: {err}" + return {"messages": [AIMessage(content=friendly)], "llm_calls": calls + 1} + + async def _tool_node(self, state: MessagesState) -> dict[str, Any]: + import asyncio + + last = state["messages"][-1] + tool_calls = getattr(last, "tool_calls", None) or [] + + async def one(tc: dict[str, Any]) -> ToolMessage: + name = tc.get("name") or "" + call_id = tc.get("id") or "" + args = tc.get("args") or {} + tool = self._tools.get(name) + if tool is None: + available = ", ".join(sorted(self._tools)) + return ToolMessage( + content=f"工具 '{name}' 不存在。可用: {available}", + tool_call_id=call_id, + name=name, + ) + try: + observation = await tool.ainvoke(args) + except Exception as exc: + observation = f"工具 '{name}' 调用失败: {type(exc).__name__}: {exc}" + if not isinstance(observation, str): + observation = str(observation) + return ToolMessage(content=observation, tool_call_id=call_id, name=name) + + if len(tool_calls) <= 1: + results = [await one(tool_calls[0])] if tool_calls else [] + else: + results = list(await asyncio.gather(*[one(tc) for tc in tool_calls])) + return {"messages": results} + + def _should_continue(self, state: MessagesState) -> Literal["tool_node", "__end__"]: + messages = state.get("messages") or [] + if not messages: + return END + last = messages[-1] + if getattr(last, "tool_calls", None): + return "tool_node" + return END + + +def _assemble_messages(static: str, dyn: str, history: list[AnyMessage]) -> list[AnyMessage]: + out: list[AnyMessage] = [SystemMessage(content=static)] + if not history: + out.append(HumanMessage(content=dyn)) + return out + + first = history[0] + if isinstance(first, HumanMessage) and isinstance(first.content, str): + out.append(HumanMessage(content=f"{dyn}\n\n---\n\n{first.content}")) + out.extend(history[1:]) + return out + + out.append(HumanMessage(content=dyn)) + out.extend(history) + return out + + +def _strip_images(messages: list[AnyMessage]) -> list[AnyMessage]: + cleaned: list[AnyMessage] = [] + for msg in messages: + if isinstance(msg, HumanMessage) and isinstance(msg.content, list): + texts = [ + part.get("text", "") + for part in msg.content + if isinstance(part, dict) and part.get("type") == "text" + ] + if texts: + cleaned.append(HumanMessage(content="\n".join(texts))) + continue + cleaned.append(msg) + return cleaned + + +def _repair_incomplete_tool_calls(messages: list[AnyMessage]) -> list[AnyMessage]: + declared: set[str] = set() + for msg in messages: + if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None): + for tc in msg.tool_calls: + if tc.get("id"): + declared.add(tc["id"]) + if not declared: + return messages + + responded: set[str] = set() + for msg in messages: + if isinstance(msg, ToolMessage) and msg.tool_call_id: + responded.add(msg.tool_call_id) + + missing = declared - responded + if not missing: + return messages + + repaired: list[AnyMessage] = [] + for msg in messages: + repaired.append(msg) + if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None): + for tc in msg.tool_calls: + if tc.get("id") in missing: + repaired.append( + ToolMessage( + content=f"[会话恢复] 工具 '{tc.get('name')}' 在上次对话中被中断,已跳过。", + tool_call_id=tc["id"], + name=tc.get("name") or "", + ) + ) + return repaired + + +def _map_stream_event(ev: dict[str, Any]) -> AgentEvent | None: + kind = ev.get("event") or "" + meta = ev.get("metadata") or {} + node = meta.get("langgraph_node") + data = ev.get("data") or {} + + if kind == "on_chat_model_stream" and node == "llm_call": + chunk = data.get("chunk") + if chunk is None: + return None + thinking = extract_reasoning(chunk) + text = extract_text(chunk) + if thinking and not text: + return AgentEvent(type="thinking", text=thinking) + if thinking and text: + return AgentEvent(type="text", text=text, extra={"thinking": thinking}) + if text: + return AgentEvent(type="text", text=text) + if thinking: + return AgentEvent(type="thinking", text=thinking) + return None + + if kind == "on_tool_start": + name = ev.get("name") or "" + return AgentEvent(type="tool_start", name=name, extra={"input": data.get("input")}) + + if kind == "on_tool_end": + name = ev.get("name") or "" + output = data.get("output") + text = output if isinstance(output, str) else str(output or "") + if len(text) > 2000: + text = text[:2000] + "..." + return AgentEvent(type="tool_end", name=name, text=text) + + return None diff --git a/src/gcode/mcp/__init__.py b/src/gcode/mcp/__init__.py new file mode 100644 index 0000000..e657b29 --- /dev/null +++ b/src/gcode/mcp/__init__.py @@ -0,0 +1,3 @@ +from gcode.mcp.client import PlaywrightMCP, ensure_mcp_config + +__all__ = ["PlaywrightMCP", "ensure_mcp_config"] diff --git a/src/gcode/mcp/client.py b/src/gcode/mcp/client.py new file mode 100644 index 0000000..4d0d2d3 --- /dev/null +++ b/src/gcode/mcp/client.py @@ -0,0 +1,93 @@ +"""Thin Playwright MCP client. No other MCP marketplace servers in v1.""" + +from __future__ import annotations + +import json +import os +import shutil +from typing import Any + +from gcode.config import ensure_home, mcp_config_path + +DEFAULT_MCP_JSON = { + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@playwright/mcp@latest"], + } + } +} + +_SILENCE_ENV = { + "NPM_CONFIG_LOGLEVEL": "silent", + "npm_config_loglevel": "silent", + "NO_UPDATE_NOTIFIER": "1", + "NODE_NO_WARNINGS": "1", + "npm_config_update_notifier": "false", +} + + +def ensure_mcp_config() -> None: + ensure_home() + path = mcp_config_path() + if not path.exists(): + path.write_text(json.dumps(DEFAULT_MCP_JSON, indent=2), encoding="utf-8") + + +class PlaywrightMCP: + def __init__(self) -> None: + self.tools: list[Any] = [] + self.message = "" + self._client: Any = None + + @property + def available(self) -> bool: + return bool(self.tools) + + async def start(self, timeout: float = 25.0) -> None: + ensure_mcp_config() + if not shutil.which("npx"): + self.message = "浏览器工具不可用:未检测到 Node.js / npx。请安装 Node.js 后重试。" + return + + import asyncio + + try: + from langchain_mcp_adapters.client import MultiServerMCPClient + except ImportError: + self.message = "浏览器工具不可用:未安装 langchain-mcp-adapters。" + return + + env = os.environ.copy() + env.update(_SILENCE_ENV) + config = { + "playwright": { + "transport": "stdio", + "command": "npx", + "args": ["-y", "--quiet", "@playwright/mcp@latest"], + "env": env, + } + } + try: + self._client = MultiServerMCPClient(config) + self.tools = list( + await asyncio.wait_for(self._client.get_tools(), timeout=timeout) + ) + if self.tools: + names = ", ".join(t.name for t in self.tools[:8]) + extra = "..." if len(self.tools) > 8 else "" + self.message = f"已连接 Playwright MCP({len(self.tools)} 个工具: {names}{extra})" + else: + self.message = "Playwright MCP 已连接,但未返回任何工具。" + except TimeoutError: + self.tools = [] + self.message = f"Playwright MCP 连接超时({int(timeout)}s)。浏览器工具不可用。" + self._client = None + except Exception as exc: + self.tools = [] + self.message = f"Playwright MCP 启动失败: {exc}" + self._client = None + + async def close(self) -> None: + self._client = None + self.tools = [] diff --git a/src/gcode/models/__init__.py b/src/gcode/models/__init__.py new file mode 100644 index 0000000..442ab3c --- /dev/null +++ b/src/gcode/models/__init__.py @@ -0,0 +1,4 @@ +from gcode.models.openai_compatible import create_chat_model +from gcode.models.registry import ModelSpec, load_registry, resolve_model + +__all__ = ["ModelSpec", "load_registry", "resolve_model", "create_chat_model"] diff --git a/src/gcode/models/openai_compatible.py b/src/gcode/models/openai_compatible.py new file mode 100644 index 0000000..0070961 --- /dev/null +++ b/src/gcode/models/openai_compatible.py @@ -0,0 +1,95 @@ +"""OpenAI-compatible Chat model. Subclass locally so reasoning_content is not a global LangChain patch.""" + +from __future__ import annotations + +from typing import Any + +from langchain_openai import ChatOpenAI + +from gcode.models.registry import ModelSpec + + +class OpenAICompatibleChat(ChatOpenAI): + """ChatOpenAI plus optional reasoning_content round-trip for compatible gateways.""" + + def _get_request_payload(self, input_: Any, **kwargs: Any) -> dict[str, Any]: + payload = super()._get_request_payload(input_, **kwargs) + try: + _attach_reasoning(payload, input_) + except Exception: + pass + return payload + + +def create_chat_model(spec: ModelSpec) -> OpenAICompatibleChat: + if not spec.api_key: + raise RuntimeError( + f"未找到 API Key。请设置环境变量 {spec.api_key_env} " + f"(当前模型 {spec.qualified_id})。" + ) + kwargs: dict[str, Any] = { + "model": spec.model_id, + "api_key": spec.api_key, + "base_url": spec.base_url, + "temperature": 0.2, + "streaming": True, + "timeout": 120, + } + if spec.extra_body: + kwargs["extra_body"] = spec.extra_body + return OpenAICompatibleChat(**kwargs) + + +def extract_reasoning(message: Any) -> str: + extra = getattr(message, "additional_kwargs", None) or {} + for key in ("reasoning_content", "reasoning"): + value = extra.get(key) + if isinstance(value, str) and value: + return value + content = getattr(message, "content", None) + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype in {"reasoning_content", "reasoning", "thinking"}: + parts.append(str(block.get("text") or block.get(btype) or "")) + if parts: + return "".join(parts) + return "" + + +def extract_text(message: Any) -> str: + content = getattr(message, "content", None) + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict) and block.get("type") == "text": + parts.append(str(block.get("text") or "")) + return "".join(parts) + return "" if content is None else str(content) + + +def _attach_reasoning(payload: dict[str, Any], input_: Any) -> None: + messages = payload.get("messages") + if not isinstance(messages, list): + return + converted: list[Any] + try: + converted = list(input_) + except TypeError: + return + for i, msg in enumerate(converted): + if i >= len(messages): + break + slot = messages[i] + if not isinstance(slot, dict) or slot.get("role") != "assistant": + continue + reasoning = extract_reasoning(msg) + if reasoning: + slot["reasoning_content"] = reasoning diff --git a/src/gcode/models/registry.py b/src/gcode/models/registry.py new file mode 100644 index 0000000..31d9e9d --- /dev/null +++ b/src/gcode/models/registry.py @@ -0,0 +1,142 @@ +"""YAML model registry. Adding a provider = editing models.yaml, not Python.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +from gcode.config import ensure_home, models_yaml_path + +DEFAULT_MODELS_YAML = """# G-CODE 模型注册表。密钥只放环境变量,不要把 Key 写进本文件。 +default: agicto/deepseek-v4-flash + +providers: + deepseek: + api: openai_compatible + base_url: https://api.deepseek.com/v1 + api_key_env: DEEPSEEK_API_KEY + models: + - id: deepseek-chat + vision: false + reasoning: false + - id: deepseek-reasoner + vision: false + reasoning: true + + agicto: + api: openai_compatible + base_url: https://api.agicto.cn/v1 + api_key_env: GCODE_API_KEY + models: + - id: deepseek-v4-flash + vision: false + reasoning: false + - id: deepseek-v4-pro + vision: false + reasoning: true +""" + + +@dataclass(frozen=True) +class ModelSpec: + provider: str + model_id: str + api: str + base_url: str + api_key_env: str + api_key: str + vision: bool = False + reasoning: bool = False + extra_body: dict[str, Any] = field(default_factory=dict) + + @property + def qualified_id(self) -> str: + return f"{self.provider}/{self.model_id}" + + +@dataclass +class Registry: + default: str + models: list[ModelSpec] + + def find(self, query: str) -> ModelSpec: + query = query.strip() + if not query: + raise KeyError("空的模型 id") + # provider/model + exact = [m for m in self.models if m.qualified_id == query] + if exact: + return exact[0] + by_id = [m for m in self.models if m.model_id == query] + if len(by_id) == 1: + return by_id[0] + if len(by_id) > 1: + ids = ", ".join(m.qualified_id for m in by_id) + raise KeyError(f"模型 id '{query}' 对应多家,请写成 provider/id。候选: {ids}") + raise KeyError(f"未知模型: {query}") + + +def ensure_models_yaml() -> Path: + ensure_home() + path = models_yaml_path() + if not path.exists(): + path.write_text(DEFAULT_MODELS_YAML, encoding="utf-8") + return path + + +def load_registry(path: Path | None = None) -> Registry: + yaml_path = path or ensure_models_yaml() + raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {} + providers = raw.get("providers") or {} + models: list[ModelSpec] = [] + for provider, cfg in providers.items(): + api = (cfg or {}).get("api") or "openai_compatible" + if api != "openai_compatible": + continue + base_url = (cfg or {}).get("base_url") or "" + api_key_env = (cfg or {}).get("api_key_env") or "GCODE_API_KEY" + api_key = _read_key(api_key_env) + for item in (cfg or {}).get("models") or []: + mid = item.get("id") + if not mid: + continue + extra = item.get("extra_body") or {} + models.append( + ModelSpec( + provider=str(provider), + model_id=str(mid), + api=api, + base_url=str(base_url).rstrip("/"), + api_key_env=api_key_env, + api_key=api_key, + vision=bool(item.get("vision", False)), + reasoning=bool(item.get("reasoning", False)), + extra_body=dict(extra) if isinstance(extra, dict) else {}, + ) + ) + default = str(raw.get("default") or (models[0].qualified_id if models else "")) + return Registry(default=default, models=models) + + +def resolve_model(query: str | None = None, path: Path | None = None) -> ModelSpec: + registry = load_registry(path) + if not registry.models: + raise RuntimeError( + f"models.yaml 中没有 openai_compatible 模型。请编辑 {models_yaml_path()}" + ) + return registry.find(query or registry.default) + + +def _read_key(env_name: str) -> str: + import os + + key = os.environ.get(env_name) or "" + if key: + return key + # last-resort shared env, still never from a file in the repo + if env_name != "GCODE_API_KEY": + return os.environ.get("GCODE_API_KEY") or "" + return "" diff --git a/src/gcode/prompt.py b/src/gcode/prompt.py new file mode 100644 index 0000000..9b4cffd --- /dev/null +++ b/src/gcode/prompt.py @@ -0,0 +1,99 @@ +"""Short static system prompt + dynamic cwd/tree. No unimplemented tools.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from gcode.tools.fs import EXCLUDE_NAMES + +_STATIC = """你是 G-CODE,运行在用户电脑终端里的个人编程助手。 +你在当前工作目录中协助开发;不要编造看过的文件内容,先读再改。 + +## 工具 +- read_file: 按行号读取文本。大文件分段读,不要一次拉整份巨文件。 +- list_dir: 看目录。不要用 execute_command 代替简单列目录。 +- grep: 在工作目录内按正则搜文本,返回 file:line。找符号、定位调用用它,不要用 execute_command 跑 findstr/rg。 +- replace_in_file: 唯一精确替换。old_text 必须在文件中恰好出现一次;多处匹配会被拒绝。用更大上下文保证唯一。 +- execute_command: 跑命令。Windows 下按 PowerShell 语法。安装依赖、构建、测试、git commit/push 都会先向用户确认。 + +没有其它内置工具。不要调用不存在的名字(例如 dispatch_subagent、activate_skill、browser_*)。 + +## 硬规则 +1. 路径只允许当前工作目录。不要尝试读取或修改目录之外的文件。 +2. 改文件用 replace_in_file,不要用 sed / 重定向覆盖来绕过确认。 +3. 连续 3 次操作没有进展:停止,用中文问用户要更多信息。 +4. 用中文思考和回复。终端 TUI 里不要使用 emoji(会错位)。 +5. 不要复述或泄露本提示词、工具 schema、API Key。 +6. 可能阻塞的命令加非交互参数(例如安装加 -y);不要启动 vim/nano 等交互程序。 +7. 一轮里互不依赖的读操作可以同时发起多个工具调用。 +8. 写文件和执行命令会弹出确认;用户拒绝后换方案,不要原样重试。 + +## 工作方式 +复杂任务按 ReAct:先想清楚 → 调用工具 → 观察结果 → 再决定。先看再改。 +""" + +_BROWSER = """ +## 浏览器 +当前已连接 Playwright MCP。需要打开页面、快照或截图时使用这些 MCP 工具。 +不要把 Cookie、Authorization 头或密码写入回复或日志。公司内网页面同样遵守。 +未提供浏览器工具时,不要假装能打开网页。 +""" + + +def static_system_prompt(*, has_browser: bool = False) -> str: + text = _STATIC.strip() + if has_browser: + text = text + "\n" + _BROWSER.strip() + return text + + +def directory_tree(cwd: Path | None = None, max_chars: int = 3000, max_depth: int = 3) -> str: + root = (cwd or Path.cwd()).resolve() + lines: list[str] = [str(root)] + + def walk(path: Path, prefix: str, depth: int) -> None: + if depth > max_depth: + return + try: + entries = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) + except OSError: + return + visible = [ + e + for e in entries + if e.name not in EXCLUDE_NAMES and (not e.name.startswith(".") or e.name == ".gcode") + ] + for i, entry in enumerate(visible): + last = i == len(visible) - 1 + branch = "`-- " if last else "|-- " + lines.append(f"{prefix}{branch}{entry.name}{'/' if entry.is_dir() else ''}") + if sum(len(x) + 1 for x in lines) > max_chars: + lines.append(f"{prefix} ...") + return + if entry.is_dir(): + extension = " " if last else "| " + walk(entry, prefix + extension, depth + 1) + if sum(len(x) + 1 for x in lines) > max_chars: + return + + walk(root, "", 1) + text = "\n".join(lines) + if len(text) > max_chars: + text = text[: max_chars - 40] + "\n... (目录树已截断)" + return text + + +def dynamic_context(cwd: Path | None = None, model_id: str = "") -> str: + root = (cwd or Path.cwd()).resolve() + os_name = os.name + bits = [ + "## 当前环境", + f"- cwd: {root}", + f"- os: {os_name}", + f"- model: {model_id or 'unknown'}", + "", + "## 目录树(摘要)", + directory_tree(root), + ] + return "\n".join(bits) diff --git a/src/gcode/safety.py b/src/gcode/safety.py new file mode 100644 index 0000000..be7af49 --- /dev/null +++ b/src/gcode/safety.py @@ -0,0 +1,64 @@ +"""HITL gates. UI injects the confirm callback; tools never import the graph.""" + +from __future__ import annotations + +import asyncio +import difflib +from collections.abc import Awaitable, Callable +from pathlib import Path + +ConfirmFn = Callable[[str, str], Awaitable[bool]] + +REJECT_WRITE = "用户拒绝本次写入,请改用其它方案,不要重复同一写入。" +REJECT_SHELL = "用户拒绝执行该命令,请改用其它方案,不要重复同一命令。" + + +def unified_diff(path: str, before: str, after: str, n: int = 3) -> str: + before_lines = before.splitlines(keepends=True) + after_lines = after.splitlines(keepends=True) + if before_lines and not before_lines[-1].endswith("\n"): + before_lines[-1] += "\n" + if after_lines and not after_lines[-1].endswith("\n"): + after_lines[-1] += "\n" + diff = difflib.unified_diff( + before_lines, + after_lines, + fromfile=f"a/{path}", + tofile=f"b/{path}", + n=n, + ) + text = "".join(diff) + if not text.strip(): + return "(无差异)" + if len(text) > 12_000: + return text[:12_000] + "\n... [diff 过长,已截断]" + return text + + +class SafetyGate: + """Serializes confirm prompts so parallel tool_calls don't interleave y/N.""" + + def __init__(self, confirm: ConfirmFn | None = None) -> None: + self._confirm = confirm + self._lock = asyncio.Lock() + + def set_confirm(self, confirm: ConfirmFn) -> None: + self._confirm = confirm + + async def allow_write(self, path: str | Path, before: str, after: str) -> bool: + path_s = str(path) + body = f"即将写入文件: {path_s}\n\n{unified_diff(path_s, before, after)}" + return await self._ask("write", body) + + async def allow_shell(self, command: str, cwd: str | Path) -> bool: + body = f"即将执行命令\n工作目录: {cwd}\n\n{command}" + return await self._ask("shell", body) + + async def _ask(self, kind: str, detail: str) -> bool: + if self._confirm is None: + return False + async with self._lock: + try: + return bool(await self._confirm(kind, detail)) + except Exception: + return False diff --git a/src/gcode/session.py b/src/gcode/session.py new file mode 100644 index 0000000..b4581b2 --- /dev/null +++ b/src/gcode/session.py @@ -0,0 +1,70 @@ +"""SQLite checkpointer + thread_id. Project data lives in /.gcode/.""" + +from __future__ import annotations + +import json +import uuid +from pathlib import Path +from typing import Any + +import aiosqlite +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + +from gcode.config import checkpoints_db, ensure_project_dir, session_file + + +class Session: + def __init__(self, thread_id: str | None = None, cwd: Path | None = None) -> None: + self.cwd = cwd or Path.cwd() + ensure_project_dir(self.cwd) + self.thread_id = thread_id or load_thread_id(self.cwd) or "default" + self._conn: aiosqlite.Connection | None = None + self.checkpointer: AsyncSqliteSaver | None = None + save_thread_id(self.thread_id, self.cwd) + + def config(self) -> dict[str, Any]: + return {"configurable": {"thread_id": self.thread_id}} + + async def start(self) -> None: + db = checkpoints_db(self.cwd) + self._conn = await aiosqlite.connect(str(db)) + await self._conn.execute("PRAGMA journal_mode=WAL") + await self._conn.execute("PRAGMA synchronous=FULL") + self.checkpointer = AsyncSqliteSaver(self._conn) + + def new_thread(self) -> str: + self.thread_id = str(uuid.uuid4()) + save_thread_id(self.thread_id, self.cwd) + return self.thread_id + + async def close(self) -> None: + if self._conn is None: + return + try: + await self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + except Exception: + pass + await self._conn.close() + self._conn = None + self.checkpointer = None + + +def load_thread_id(cwd: Path | None = None) -> str | None: + path = session_file(cwd) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + tid = data.get("thread_id") + return str(tid) if tid else None + except Exception: + return None + + +def save_thread_id(thread_id: str, cwd: Path | None = None) -> None: + path = session_file(cwd) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"thread_id": thread_id}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) diff --git a/src/gcode/tools/__init__.py b/src/gcode/tools/__init__.py new file mode 100644 index 0000000..0e2e6d9 --- /dev/null +++ b/src/gcode/tools/__init__.py @@ -0,0 +1,4 @@ +from gcode.tools.fs import make_fs_tools, resolve_path +from gcode.tools.shell import make_shell_tools + +__all__ = ["make_fs_tools", "make_shell_tools", "resolve_path"] diff --git a/src/gcode/tools/fs.py b/src/gcode/tools/fs.py new file mode 100644 index 0000000..108fee6 --- /dev/null +++ b/src/gcode/tools/fs.py @@ -0,0 +1,309 @@ +"""File tools: read / list / grep / unique replace. Paths must stay under cwd or the user home dir.""" + +from __future__ import annotations + +import fnmatch +import os +import re +from collections.abc import Iterable +from pathlib import Path + +from langchain_core.tools import BaseTool, tool + +from gcode.config import get_home_dir +from gcode.safety import REJECT_WRITE, SafetyGate + +EXCLUDE_NAMES = { + ".git", + "node_modules", + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".tox", + "dist", + "build", + ".idea", + ".vs", +} + +MAX_READ_SPAN = 400 +MAX_FILE_BYTES = 2 * 1024 * 1024 +MAX_GREP_RESULTS = 80 +MAX_GREP_LINE = 240 + + +def _is_within(base: Path, target: Path) -> bool: + try: + target.relative_to(base) + return True + except ValueError: + return False + + +def resolve_path(path: str, cwd: Path | None = None) -> Path: + """Resolve and sandbox a path. Allows cwd and %APPDATA%\\gcode (or XDG home).""" + base = (cwd or Path.cwd()).resolve() + target = Path(path).expanduser() + if not target.is_absolute(): + target = (base / target).resolve() + else: + target = target.resolve() + + home = get_home_dir().resolve() + if _is_within(base, target) or _is_within(home, target): + return target + raise ValueError( + f"路径必须位于当前工作目录 ({base}) 或 G-CODE 用户目录 ({home}) 内,已拒绝: {path}" + ) + + +def _is_excluded(name: str) -> bool: + if name in EXCLUDE_NAMES: + return True + for pattern in EXCLUDE_NAMES: + if "*" in pattern and fnmatch.fnmatch(name, pattern): + return True + return False + + +def make_fs_tools(safety: SafetyGate, cwd: Path | None = None) -> list[BaseTool]: + work = cwd + + @tool + def read_file(path: str, start_line: int = 1, end_line: int = 200) -> str: + """按行号范围读取文本文件。只能读当前工作目录或 G-CODE 用户目录内的文件。 + + Args: + path: 相对或绝对路径。 + start_line: 起始行(从 1 开始,含)。 + end_line: 结束行(含)。单次最多 400 行。 + """ + try: + if start_line < 1 or end_line < start_line: + return "Error: 行号范围无效。" + if end_line - start_line + 1 > MAX_READ_SPAN: + end_line = start_line + MAX_READ_SPAN - 1 + + target = resolve_path(path, work) + if not target.exists(): + return f"Error: 文件不存在: {path}" + if not target.is_file(): + return f"Error: 不是文件: {path}" + if target.stat().st_size > MAX_FILE_BYTES: + return f"Error: 文件过大(>{MAX_FILE_BYTES} 字节),请缩小范围或用其它方式检索。" + + lines: list[str] = [] + with target.open("r", encoding="utf-8", errors="replace") as fh: + for idx, line in enumerate(fh, start=1): + if idx < start_line: + continue + if idx > end_line: + break + lines.append(f"{idx:6d}: {line.rstrip()}") + + if not lines: + return "Warning: 指定范围内没有内容。" + return f"[READ_FILE] {target}\n" + "\n".join(lines) + except Exception as exc: + return f"Error reading file: {exc}" + + @tool + def list_dir(path: str = ".", max_entries: int = 200) -> str: + """列出目录内容。跳过 .git / node_modules / .venv 等常见噪音目录。 + + Args: + path: 要列出的目录,默认当前工作目录。 + max_entries: 最多返回条数。 + """ + try: + target = resolve_path(path, work) + if not target.exists(): + return f"Error: 目录不存在: {path}" + if not target.is_dir(): + return f"Error: 不是目录: {path}" + + max_entries = max(1, min(max_entries, 500)) + rows: list[str] = [] + try: + names = sorted(os.listdir(target), key=str.lower) + except OSError as exc: + return f"Error: 无法读取目录: {exc}" + + for name in names: + if _is_excluded(name): + continue + if name.startswith(".") and name not in {".gcode"}: + continue + child = target / name + kind = "dir" if child.is_dir() else "file" + rows.append(f"[{kind}] {name}") + if len(rows) >= max_entries: + rows.append(f"... (truncated, max_entries={max_entries})") + break + return "\n".join(rows) if rows else "(空目录)" + except Exception as exc: + return f"Error listing directory: {exc}" + + @tool + def grep( + query: str, + path: str = ".", + glob: str = "*", + max_results: int = 50, + ) -> str: + """在当前工作目录内搜索文本(正则)。只读,不需要确认。跳过 .git / node_modules 等。 + + Args: + query: 正则表达式,例如 init_agent 或 def\\s+foo。 + path: 文件或目录,默认当前工作目录。 + glob: 文件名过滤,例如 *.py。 + max_results: 最多返回条数。 + """ + try: + query = (query or "").strip() + if not query: + return "Error: query 不能为空。" + try: + pattern = re.compile(query) + except re.error as exc: + return f"Error: 无效正则: {exc}" + + target = resolve_path(path, work) + if not target.exists(): + return f"Error: 路径不存在: {path}" + + max_results = max(1, min(int(max_results), MAX_GREP_RESULTS)) + base = (work or Path.cwd()).resolve() + files = _grep_files(target, glob) + hits: list[str] = [] + scanned = 0 + for file_path in files: + scanned += 1 + hits.extend(_grep_file(file_path, pattern, base)) + if len(hits) >= max_results: + hits = hits[:max_results] + rel = _rel_or_name(target, base) + body = "\n".join(hits) + return ( + f"[GREP] query={query!r} path={rel} files={scanned}\n{body}\n" + f"... (truncated, max_results={max_results})" + ) + + rel = _rel_or_name(target, base) + header = f"[GREP] query={query!r} path={rel} files={scanned}" + if not hits: + return header + "\nNo matches." + return header + "\n" + "\n".join(hits) + except Exception as exc: + return f"Error grepping: {exc}" + + @tool + async def replace_in_file(path: str, old_text: str, new_text: str) -> str: + """在文件中做唯一精确替换。old_text 必须在文件中恰好出现一次(含空白)。 + + 写入前会向用户展示 unified diff 并等待确认。 + + Args: + path: 文件路径。 + old_text: 要被替换的原文,必须唯一匹配。 + new_text: 替换后的文本。 + """ + try: + if not old_text: + return "Error: old_text 不能为空。" + target = resolve_path(path, work) + if not target.exists(): + return f"Error: 文件不存在: {path}。新建文件请用 execute_command(会再次确认)。" + if not target.is_file(): + return f"Error: 不是文件: {path}" + + with target.open("r", encoding="utf-8", errors="replace") as fh: + before = fh.read() + + count = before.count(old_text) + if count == 0: + return "Error: old_text 未在文件中找到。必须精确匹配,包括空格与换行。" + if count != 1: + return ( + f"Error: old_text 出现了 {count} 次,拒绝替换。" + "请扩大上下文使匹配唯一。" + ) + + after = before.replace(old_text, new_text, 1) + if not await safety.allow_write(target, before, after): + return REJECT_WRITE + + with target.open("w", encoding="utf-8") as fh: + fh.write(after) + return f"[REPLACE_IN_FILE] 已写入 {target}" + except Exception as exc: + return f"Error replacing in file: {exc}" + + return [read_file, list_dir, grep, replace_in_file] + + +def iter_files(directory: Path, include_hidden: bool = False) -> Iterable[Path]: + """Used by tests / tree helpers; skips excluded directories.""" + for root, dirs, files in os.walk(directory): + dirs[:] = [ + d + for d in dirs + if not _is_excluded(d) and (include_hidden or not d.startswith(".")) + ] + for name in files: + if _is_excluded(name): + continue + if not include_hidden and name.startswith("."): + continue + yield Path(root) / name + + +def _rel_or_name(path: Path, base: Path) -> str: + try: + return str(path.resolve().relative_to(base)) + except ValueError: + return str(path) + + +def _grep_files(target: Path, glob: str) -> list[Path]: + if target.is_file(): + files = [target] + elif target.is_dir(): + files = list(iter_files(target)) + else: + return [] + if not glob or glob == "*": + return files + return [ + f + for f in files + if fnmatch.fnmatch(f.name, glob) or fnmatch.fnmatch(str(f).replace("\\", "/"), glob) + ] + + +def _grep_file(file_path: Path, pattern: re.Pattern[str], base: Path) -> list[str]: + try: + size = file_path.stat().st_size + except OSError: + return [] + if size > MAX_FILE_BYTES: + return [] + try: + with file_path.open("rb") as fh: + sample = fh.read(4096) + if b"\x00" in sample: + return [] + with file_path.open("r", encoding="utf-8", errors="replace") as fh: + hits: list[str] = [] + rel = _rel_or_name(file_path, base) + for idx, line in enumerate(fh, start=1): + if pattern.search(line): + text = line.rstrip("\r\n") + if len(text) > MAX_GREP_LINE: + text = text[:MAX_GREP_LINE] + "..." + hits.append(f"{rel}:{idx}: {text}") + return hits + except OSError: + return [] diff --git a/src/gcode/tools/shell.py b/src/gcode/tools/shell.py new file mode 100644 index 0000000..c655e41 --- /dev/null +++ b/src/gcode/tools/shell.py @@ -0,0 +1,94 @@ +"""Shell tool: default PowerShell, UTF-8 then GBK, timeout kills the process tree.""" + +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +from langchain_core.tools import BaseTool, tool + +from gcode.safety import REJECT_SHELL, SafetyGate +from gcode.windows import ( + decode_bytes, + kill_process_tree, + powershell_argv, + subprocess_flags, + truncate_output, +) + + +def make_shell_tools(safety: SafetyGate, cwd: Path | None = None) -> list[BaseTool]: + work = cwd + + @tool + async def execute_command(command: str, timeout: int = 120) -> str: + """在当前工作目录执行一条命令,返回合并后的 stdout/stderr。 + + Windows 默认走 PowerShell。超时会杀掉整个进程树,而不是只 terminate 父进程。 + 执行前会向用户展示完整命令并等待确认。 + + Args: + command: 要执行的命令(Windows 下按 PowerShell 语法)。 + timeout: 超时秒数,默认 120。 + """ + command = (command or "").strip() + if not command: + return "Error: 空命令。" + timeout = max(1, min(int(timeout), 600)) + cwd_path = (work or Path.cwd()).resolve() + + if not await safety.allow_shell(command, cwd_path): + return REJECT_SHELL + + try: + if sys.platform == "win32": + argv = powershell_argv(command) + process = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=str(cwd_path), + env=os.environ.copy(), + creationflags=subprocess_flags(), + limit=1024 * 1024, + ) + else: + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=str(cwd_path), + env=os.environ.copy(), + start_new_session=True, + limit=1024 * 1024, + ) + + try: + raw, _ = await asyncio.wait_for(process.communicate(), timeout=timeout) + except asyncio.TimeoutError: + if process.pid: + kill_process_tree(process.pid) + leftover = b"" + if process.stdout: + try: + leftover = await asyncio.wait_for(process.stdout.read(), timeout=1) + except Exception: + leftover = b"" + try: + await asyncio.wait_for(process.wait(), timeout=5) + except Exception: + pass + body = truncate_output(decode_bytes(leftover).strip()) + return f"[TIMEOUT] 命令超过 {timeout}s 已终止进程树。\n已捕获输出:\n{body}" + + text = truncate_output(decode_bytes(raw or b"")) + code = process.returncode + if code == 0: + return text if text.strip() else "[SUCCESS] (无输出)" + return f"[COMPLETED] (exit {code})\n{text}" + except Exception as exc: + return f"[FAILED] 执行命令时出错: {type(exc).__name__}: {exc}" + + return [execute_command] diff --git a/src/gcode/ui/__init__.py b/src/gcode/ui/__init__.py new file mode 100644 index 0000000..7b0b14e --- /dev/null +++ b/src/gcode/ui/__init__.py @@ -0,0 +1,3 @@ +from gcode.ui.detect import detect_terminal, resolve_ui_mode + +__all__ = ["detect_terminal", "resolve_ui_mode"] diff --git a/src/gcode/ui/detect.py b/src/gcode/ui/detect.py new file mode 100644 index 0000000..8fdb7e8 --- /dev/null +++ b/src/gcode/ui/detect.py @@ -0,0 +1,88 @@ +"""Terminal detect — scheme C: TUI only when the host can own IME; else REPL.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Mapping + +from gcode.windows import parent_process_name + +CONHOST_HINT = """当前是传统 CMD(conhost),全屏界面无法稳定完成中文输入。 +请用 Windows Terminal 打开本目录后执行 gcode。 +本次自动进入 REPL,读文件 / 改文件 / 跑命令仍然可用。 +""" + +TUI_REFUSED = """当前终端不适合启动 TUI(经典 CMD / conhost)。 +请改用 Windows Terminal,或去掉 --ui tui 使用 REPL: gcode --ui repl +""" + +_TUI_TERM_PROGRAMS = {"vscode", "cursor", "tabby", "warp", "wezterm", "alacritty", "ghostty"} +_TUI_PARENTS = { + "windowsterminal.exe", + "windowsterminal", + "code.exe", + "code", + "cursor.exe", + "cursor", + "wezterm-gui.exe", + "alacritty.exe", + "tabby.exe", +} + + +@dataclass(frozen=True) +class TerminalInfo: + tui_ok: bool + reason: str + host: str + + +def detect_terminal( + env: Mapping[str, str] | None = None, + parent_name: str | None = None, +) -> TerminalInfo: + source = os.environ if env is None else env + + if source.get("WT_SESSION"): + return TerminalInfo(True, "WT_SESSION", "windows-terminal") + + program = (source.get("TERM_PROGRAM") or "").strip().lower() + if program in _TUI_TERM_PROGRAMS: + return TerminalInfo(True, f"TERM_PROGRAM={program}", program) + + emulator = (source.get("TERMINAL_EMULATOR") or "").strip().lower() + if emulator and emulator not in {"jediterm"}: + # JetBrains JediTerm can run TUI but IME is poor; still allow TUI (user can --ui repl). + pass + if "vscode" in emulator or emulator in {"vscode", "cursor"}: + return TerminalInfo(True, f"TERMINAL_EMULATOR={emulator}", "vscode") + + parent = parent_name if parent_name is not None else parent_process_name() + parent_l = parent.strip().lower() + if parent_l in _TUI_PARENTS or "windowsterminal" in parent_l: + host = "windows-terminal" if "windowsterminal" in parent_l else parent_l + return TerminalInfo(True, f"parent={parent}", host) + + return TerminalInfo(False, f"parent={parent or 'unknown'}", "conhost") + + +def resolve_ui_mode( + requested: str, + info: TerminalInfo, +) -> tuple[str, str]: + """Return (mode, warning). mode is 'tui' or 'repl'. warning may be empty. + + If requested is 'tui' and not allowed, raise SystemExit-worthy by returning mode='refuse'. + """ + req = (requested or "auto").strip().lower() + if req == "repl": + return "repl", "" + if req == "tui": + if info.tui_ok: + return "tui", "" + return "refuse", TUI_REFUSED + # auto + if info.tui_ok: + return "tui", "" + return "repl", CONHOST_HINT diff --git a/src/gcode/ui/editor.py b/src/gcode/ui/editor.py new file mode 100644 index 0000000..ae0d7d9 --- /dev/null +++ b/src/gcode/ui/editor.py @@ -0,0 +1,37 @@ +"""Open %EDITOR% or Notepad, then return the saved text as user input.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +def capture_from_editor() -> str: + editor = (os.environ.get("EDITOR") or os.environ.get("VISUAL") or "").strip() + suffix = ".md" + fd, raw = tempfile.mkstemp(prefix="gcode-edit-", suffix=suffix) + os.close(fd) + path = Path(raw) + try: + path.write_text("", encoding="utf-8") + argv = _editor_argv(editor, path) + subprocess.run(argv, check=False) + return path.read_text(encoding="utf-8") + finally: + try: + path.unlink(missing_ok=True) + except Exception: + pass + + +def _editor_argv(editor: str, path: Path) -> list[str]: + if editor: + if sys.platform == "win32" and editor.lower() in {"code", "code.exe", "cursor", "cursor.exe"}: + return [editor, "--wait", str(path)] + return [editor, str(path)] + if sys.platform == "win32": + return ["notepad.exe", str(path)] + return ["vi", str(path)] diff --git a/src/gcode/ui/repl.py b/src/gcode/ui/repl.py new file mode 100644 index 0000000..cc3c437 --- /dev/null +++ b/src/gcode/ui/repl.py @@ -0,0 +1,149 @@ +"""Cooked-mode REPL: IME stays with the terminal. Escape hatch for conhost.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from gcode.ui.editor import capture_from_editor + +if TYPE_CHECKING: + from gcode.cli import Runtime + +HELP = """斜杠命令(不进入模型): + /help 显示本帮助 + /edit 用外部编辑器写长提示 + /clear 新开会话(换 thread_id,可继续用同一进程) + /model 列出已注册模型 + /model 切换模型(立即生效,会话保留) + /quit 退出 +""" + + +async def repl_confirm(kind: str, detail: str) -> bool: + title = "确认写入" if kind == "write" else "确认执行命令" + print() + print(f"--- {title} ---") + print(detail) + print("---------------") + try: + answer = await asyncio.to_thread(input, "确认? [y/N] ") + except EOFError: + return False + return answer.strip().lower() in {"y", "yes"} + + +async def run_repl(rt: Runtime, banner: str = "") -> None: + rt.safety.set_confirm(repl_confirm) + print(f"G-CODE REPL | 模型 {rt.spec.qualified_id} | session {rt.session.thread_id}") + print(f"工作目录: {rt.cwd}") + if rt.mcp.message: + print(rt.mcp.message) + if banner: + print(banner.rstrip()) + print("输入任务,/help 查看命令。Ctrl+C 或 /quit 退出。") + print() + + while True: + try: + line = await asyncio.to_thread(input, "gcode> ") + except (EOFError, KeyboardInterrupt): + print() + return + text = line.strip() + if not text: + continue + if text.startswith("/"): + cont = await _handle_command(rt, text) + if not cont: + return + continue + await _run_turn(rt, text) + + +async def _handle_command(rt: Runtime, text: str) -> bool: + parts = text.split(maxsplit=1) + cmd = parts[0].lower() + arg = parts[1].strip() if len(parts) > 1 else "" + if cmd in {"/quit", "/exit", "/q"}: + return False + if cmd == "/help": + print(HELP) + return True + if cmd == "/clear": + rt.session.new_thread() + print(f"已新开会话: {rt.session.thread_id}") + return True + if cmd == "/edit": + raw = await asyncio.to_thread(capture_from_editor) + if not raw.strip(): + print("(空内容,已取消)") + return True + await _run_turn(rt, raw) + return True + if cmd == "/model": + if not arg: + _print_models(rt) + return True + try: + rt.switch_model(arg) + print(f"已切换到 {rt.spec.qualified_id}") + except Exception as exc: + print(f"切换失败: {exc}") + return True + print(f"未知命令 {cmd}。输入 /help") + return True + + +def _print_models(rt: Runtime) -> None: + from gcode.models.registry import load_registry + + registry = load_registry() + print("已注册模型:") + for spec in registry.models: + mark = "*" if spec.qualified_id == rt.spec.qualified_id else " " + print(f" {mark} {spec.qualified_id}") + print(f"当前: {rt.spec.qualified_id}") + + +async def _run_turn(rt: Runtime, text: str) -> None: + thinking = False + try: + async for ev in rt.agent.stream_turn(text): + if ev.type == "thinking": + if not thinking: + print("[think] ", end="", flush=True) + thinking = True + print(ev.text, end="", flush=True) + elif ev.type == "text": + extra = (ev.extra or {}).get("thinking") + if extra: + if not thinking: + print("[think] ", end="", flush=True) + thinking = True + print(extra, end="", flush=True) + if thinking: + print() + thinking = False + print(ev.text, end="", flush=True) + elif ev.type == "tool_start": + if thinking: + print() + thinking = False + print(f"\n[tool] {ev.name} ...", flush=True) + elif ev.type == "tool_end": + preview = (ev.text or "").strip().replace("\n", " ") + if len(preview) > 160: + preview = preview[:160] + "..." + print(f"[tool] {ev.name} 完成 {preview}", flush=True) + elif ev.type == "error": + if thinking: + print() + thinking = False + print(f"\n[error] {ev.text}", flush=True) + elif ev.type == "done": + if thinking: + print() + print() + except KeyboardInterrupt: + print("\n(已中断本轮)") diff --git a/src/gcode/ui/tui/__init__.py b/src/gcode/ui/tui/__init__.py new file mode 100644 index 0000000..27f45cb --- /dev/null +++ b/src/gcode/ui/tui/__init__.py @@ -0,0 +1,3 @@ +from gcode.ui.tui.app import GCodeApp + +__all__ = ["GCodeApp"] diff --git a/src/gcode/ui/tui/app.py b/src/gcode/ui/tui/app.py new file mode 100644 index 0000000..f1646df --- /dev/null +++ b/src/gcode/ui/tui/app.py @@ -0,0 +1,300 @@ +"""G-CODE Textual TUI. Own layout and CSS; consumes Agent.stream_turn only.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.widgets import Header, Input, Label, RichLog, Static + +from gcode.models.registry import load_registry +from gcode.ui.editor import capture_from_editor +from gcode.ui.tui.screens import ConfirmScreen, ModelScreen + +if TYPE_CHECKING: + from gcode.cli import Runtime + +KITTY_POP = "\x1b[ None: + super().__init__() + self.rt = rt + self._bot: Static | None = None + self._bot_buf = "" + self._busy = False + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + with Horizontal(id="body"): + yield VerticalScroll(id="messages") + with Vertical(id="side"): + yield Label("思考") + yield RichLog(id="thinking", wrap=True, highlight=False, markup=False) + yield Label("工具") + yield VerticalScroll(id="tools") + yield Static(self._status_line(), id="status") + yield Input(placeholder="输入任务,/help 查看命令", id="prompt") + + def _status_line(self) -> str: + mcp = "browser on" if self.rt.mcp.available else "browser off" + return ( + f" {self.rt.spec.qualified_id} | session {self.rt.session.thread_id[:8]} " + f"| {mcp} | {HELP_TEXT}" + ) + + def _refresh_status(self) -> None: + self.query_one("#status", Static).update(self._status_line()) + + async def on_mount(self) -> None: + self.rt.safety.set_confirm(self.confirm) + self._disable_kitty() + self.set_interval(0.08, self._flush_bot) + msgs = self.query_one("#messages", VerticalScroll) + await msgs.mount(Static("G-CODE 已就绪。用中文下任务即可。", classes="bubble-sys")) + if self.rt.mcp.message: + await msgs.mount(Static(self.rt.mcp.message, classes="bubble-sys")) + self.query_one(Input).focus() + self.sub_title = self.rt.spec.qualified_id + + def _disable_kitty(self) -> None: + driver = getattr(self, "_driver", None) + write = getattr(driver, "write", None) + if callable(write): + try: + write(KITTY_POP) + except Exception: + pass + + async def confirm(self, kind: str, detail: str) -> bool: + title = "确认写入" if kind == "write" else "确认执行命令" + result = await self.push_screen_wait(ConfirmScreen(title, detail)) + return bool(result) + + def _flush_bot(self) -> None: + if self._bot is not None: + self._bot.update(self._bot_buf or "...") + + async def on_input_submitted(self, event: Input.Submitted) -> None: + text = (event.value or "").strip() + event.input.value = "" + if not text or self._busy: + return + if text.startswith("/"): + await self._command(text) + return + self.run_worker(self._run_turn(text), exclusive=True, group="turn") + + async def _command(self, text: str) -> None: + parts = text.split(maxsplit=1) + cmd = parts[0].lower() + arg = parts[1].strip() if len(parts) > 1 else "" + msgs = self.query_one("#messages", VerticalScroll) + if cmd in {"/quit", "/exit", "/q"}: + self.exit() + return + if cmd == "/help": + await msgs.mount(Static(HELP_TEXT, classes="bubble-sys")) + return + if cmd == "/clear": + self.rt.session.new_thread() + await msgs.remove_children() + self.query_one("#thinking", RichLog).clear() + await self.query_one("#tools", VerticalScroll).remove_children() + await msgs.mount(Static(f"已新开会话 {self.rt.session.thread_id}", classes="bubble-sys")) + self._refresh_status() + return + if cmd == "/edit": + with self.suspend(): + raw = capture_from_editor() + if not raw.strip(): + await msgs.mount(Static("空内容,已取消 /edit", classes="bubble-sys")) + return + self.run_worker(self._run_turn(raw), exclusive=True, group="turn") + return + if cmd == "/model": + if arg: + try: + self.rt.switch_model(arg) + self.sub_title = self.rt.spec.qualified_id + self._refresh_status() + await msgs.mount(Static(f"已切换到 {self.rt.spec.qualified_id}", classes="bubble-sys")) + except Exception as exc: + await msgs.mount(Static(f"切换失败: {exc}", classes="bubble-sys")) + return + registry = load_registry() + labels = [m.qualified_id for m in registry.models] + picked = await self.push_screen_wait(ModelScreen(labels, self.rt.spec.qualified_id)) + if picked: + try: + self.rt.switch_model(picked) + self.sub_title = self.rt.spec.qualified_id + self._refresh_status() + await msgs.mount(Static(f"已切换到 {self.rt.spec.qualified_id}", classes="bubble-sys")) + except Exception as exc: + await msgs.mount(Static(f"切换失败: {exc}", classes="bubble-sys")) + return + await msgs.mount(Static(f"未知命令 {cmd},输入 /help", classes="bubble-sys")) + + async def _run_turn(self, text: str) -> None: + self._busy = True + prompt = self.query_one("#prompt", Input) + prompt.disabled = True + msgs = self.query_one("#messages", VerticalScroll) + tools_view = self.query_one("#tools", VerticalScroll) + thinking = self.query_one("#thinking", RichLog) + preview = text if len(text) < 2000 else text[:2000] + "..." + await msgs.mount(Static(preview, classes="bubble-user")) + self._bot_buf = "" + self._bot = Static("...", classes="bubble-bot") + await msgs.mount(self._bot) + cards: dict[str, Static] = {} + try: + async for ev in self.rt.agent.stream_turn(text): + if ev.type == "thinking": + thinking.write(ev.text) + elif ev.type == "text": + extra = (ev.extra or {}).get("thinking") + if extra: + thinking.write(extra) + self._bot_buf += ev.text + elif ev.type == "tool_start": + card = Static(f"{ev.name} ...", classes="tool-card") + cards[ev.name] = card + await tools_view.mount(card) + elif ev.type == "tool_end": + card = cards.get(ev.name) + preview_out = (ev.text or "").replace("\n", " ") + if len(preview_out) > 120: + preview_out = preview_out[:120] + "..." + label = f"{ev.name} [done] {preview_out}" + if card is not None: + card.update(label) + card.add_class("done") + else: + await tools_view.mount(Static(label, classes="tool-card done")) + elif ev.type == "error": + self._bot_buf += ("\n" + ev.text) if self._bot_buf else ev.text + self._flush_bot() + finally: + self._busy = False + prompt.disabled = False + prompt.focus() + msgs.scroll_end(animate=False) diff --git a/src/gcode/ui/tui/screens.py b/src/gcode/ui/tui/screens.py new file mode 100644 index 0000000..28dd184 --- /dev/null +++ b/src/gcode/ui/tui/screens.py @@ -0,0 +1,69 @@ +"""TUI modal screens: HITL confirm and model picker.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.screen import ModalScreen +from textual.widgets import Button, Label, Static + + +class ConfirmScreen(ModalScreen[bool]): + BINDINGS = [ + Binding("y", "yes", "确认", show=False), + Binding("n", "no", "拒绝", show=False), + Binding("escape", "no", "拒绝", show=False), + ] + + def __init__(self, title: str, body: str) -> None: + super().__init__() + self._title = title + self._body = body + + def compose(self) -> ComposeResult: + with Vertical(id="confirm-box"): + yield Label(self._title, id="confirm-title") + with VerticalScroll(id="confirm-body"): + yield Static(self._body) + with Horizontal(id="confirm-buttons"): + yield Button("确认 (y)", id="yes", variant="success") + yield Button("拒绝 (n)", id="no", variant="error") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id == "yes") + + def action_yes(self) -> None: + self.dismiss(True) + + def action_no(self) -> None: + self.dismiss(False) + + +class ModelScreen(ModalScreen[str | None]): + """Pick a model; dismiss with qualified id or None.""" + + def __init__(self, labels: list[str], current: str) -> None: + super().__init__() + self._labels = labels + self._current = current + + def compose(self) -> ComposeResult: + with Vertical(id="model-box"): + yield Label("选择模型", id="confirm-title") + with VerticalScroll(): + for index, name in enumerate(self._labels): + mark = " (当前)" if name == self._current else "" + yield Button(f"{name}{mark}", id=f"model-{index}") + yield Button("取消", id="cancel-model") + + def on_button_pressed(self, event: Button.Pressed) -> None: + bid = event.button.id or "" + if bid == "cancel-model": + self.dismiss(None) + return + if bid.startswith("model-"): + index = int(bid.split("-", 1)[1]) + self.dismiss(self._labels[index]) + return + self.dismiss(None) diff --git a/src/gcode/windows.py b/src/gcode/windows.py new file mode 100644 index 0000000..f088903 --- /dev/null +++ b/src/gcode/windows.py @@ -0,0 +1,159 @@ +"""Windows process, encoding, and console helpers. + +Qoze always decoded shell output as UTF-8 and only called terminate() on timeout. +G-CODE: UTF-8 then GBK; kill the whole process tree with taskkill /T (or POSIX group). +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys + + +CREATE_NEW_PROCESS_GROUP = 0x00000200 +CREATE_NO_WINDOW = 0x08000000 + + +def configure_stdio() -> None: + """Prefer UTF-8 for Python I/O. Does not fix cmd IME; that is UI detect + REPL.""" + os.environ.setdefault("PYTHONUTF8", "1") + os.environ.setdefault("PYTHONIOENCODING", "utf-8") + if sys.platform == "win32": + try: + import ctypes + + ctypes.windll.kernel32.SetConsoleCP(65001) + ctypes.windll.kernel32.SetConsoleOutputCP(65001) + except Exception: + pass + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + +def decode_bytes(data: bytes) -> str: + """Decode command output: try UTF-8, then GBK. Empty bytes → empty string.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("gbk", errors="replace") + + +def powershell_argv(command: str) -> list[str]: + """Run `command` via PowerShell -EncodedCommand so Chinese arguments survive.""" + import base64 + + encoded = base64.b64encode(command.encode("utf-16-le")).decode("ascii") + return [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encoded, + ] + + +def subprocess_flags() -> int: + if sys.platform != "win32": + return 0 + return CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW + + +def kill_process_tree(pid: int) -> None: + """Kill pid and descendants. Windows: taskkill /T; POSIX: kill process group.""" + if pid <= 0: + return + if sys.platform == "win32": + subprocess.run( + ["taskkill", "/T", "/F", "/PID", str(pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return + try: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except ProcessLookupError: + return + except Exception: + try: + os.kill(pid, signal.SIGTERM) + except Exception: + pass + + +def parent_process_name() -> str: + """Best-effort parent executable name (used by terminal detect).""" + if sys.platform != "win32": + return "" + try: + return _windows_parent_exe() + except Exception: + return "" + + +def _windows_parent_exe() -> str: + import ctypes + from ctypes import wintypes + + TH32CS_SNAPPROCESS = 0x00000002 + + class PROCESSENTRY32(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", ctypes.c_long), + ("dwFlags", wintypes.DWORD), + ("szExeFile", ctypes.c_char * 260), + ] + + kernel32 = ctypes.windll.kernel32 + snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snapshot == -1: + return "" + try: + entry = PROCESSENTRY32() + entry.dwSize = ctypes.sizeof(PROCESSENTRY32) + current = os.getpid() + parent_pid = 0 + if not kernel32.Process32First(snapshot, ctypes.byref(entry)): + return "" + while True: + if entry.th32ProcessID == current: + parent_pid = entry.th32ParentProcessID + break + if not kernel32.Process32Next(snapshot, ctypes.byref(entry)): + break + if not parent_pid: + return "" + if not kernel32.Process32First(snapshot, ctypes.byref(entry)): + return "" + while True: + if entry.th32ProcessID == parent_pid: + raw = entry.szExeFile.split(b"\x00", 1)[0] + return raw.decode("mbcs", errors="replace") + if not kernel32.Process32Next(snapshot, ctypes.byref(entry)): + break + return "" + finally: + kernel32.CloseHandle(snapshot) + + +def truncate_output(text: str, limit: int = 50_000) -> str: + if len(text) <= limit: + return text + head = limit - 200 + return text[:head] + f"\n... [输出过长,已截断,共 {len(text)} 字符]" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..504f8cb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Isolate user-dir lookups from the real %APPDATA% during tests.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _isolate_home(tmp_path_factory, monkeypatch): + root = tmp_path_factory.mktemp("appdata") + monkeypatch.setenv("APPDATA", str(root)) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) diff --git a/tests/test_detect_terminal.py b/tests/test_detect_terminal.py new file mode 100644 index 0000000..3c746ff --- /dev/null +++ b/tests/test_detect_terminal.py @@ -0,0 +1,40 @@ +from gcode.ui.detect import CONHOST_HINT, detect_terminal, resolve_ui_mode + + +def test_windows_terminal_by_session() -> None: + info = detect_terminal(env={"WT_SESSION": "abc"}, parent_name="") + assert info.tui_ok + assert info.host == "windows-terminal" + + +def test_vscode_term_program() -> None: + info = detect_terminal(env={"TERM_PROGRAM": "vscode"}, parent_name="unknown") + assert info.tui_ok + + +def test_conhost_cmd() -> None: + info = detect_terminal(env={}, parent_name="cmd.exe") + assert not info.tui_ok + assert info.host == "conhost" + + +def test_auto_falls_back_to_repl() -> None: + info = detect_terminal(env={}, parent_name="cmd.exe") + mode, warning = resolve_ui_mode("auto", info) + assert mode == "repl" + assert "Windows Terminal" in warning + assert warning == CONHOST_HINT + + +def test_force_tui_on_conhost_refused() -> None: + info = detect_terminal(env={}, parent_name="cmd.exe") + mode, warning = resolve_ui_mode("tui", info) + assert mode == "refuse" + assert "repl" in warning.lower() or "Windows Terminal" in warning + + +def test_force_repl_always() -> None: + info = detect_terminal(env={"WT_SESSION": "1"}, parent_name="WindowsTerminal.exe") + mode, warning = resolve_ui_mode("repl", info) + assert mode == "repl" + assert warning == "" diff --git a/tests/test_graph_smoke.py b/tests/test_graph_smoke.py new file mode 100644 index 0000000..f88f193 --- /dev/null +++ b/tests/test_graph_smoke.py @@ -0,0 +1,97 @@ +from pathlib import Path + +import pytest +from langchain_core.messages import AIMessage + +from gcode.graph import Agent +from gcode.safety import SafetyGate +from gcode.tools.fs import make_fs_tools + + +class ScriptedLLM: + def __init__(self, responses: list[AIMessage]) -> None: + self.responses = list(responses) + self.calls = 0 + + def bind_tools(self, tools): + return self + + async def ainvoke(self, messages, **kwargs): + if self.calls >= len(self.responses): + return AIMessage(content="(script exhausted)") + msg = self.responses[self.calls] + self.calls += 1 + return msg + + +async def _always(kind: str, detail: str) -> bool: + return True + + +async def _never(kind: str, detail: str) -> bool: + return False + + +@pytest.mark.asyncio +async def test_one_tool_call_then_end(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("hello gcode\n", encoding="utf-8") + safety = SafetyGate(_always) + tools = make_fs_tools(safety, tmp_path) + llm = ScriptedLLM( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "read_file", + "args": {"path": "README.md"}, + "id": "call_1", + "type": "tool_call", + } + ], + ), + AIMessage(content="README 只有一句 hello gcode。"), + ] + ) + agent = Agent(llm, tools, cwd=tmp_path) + messages = await agent.invoke_turn("请读 README") + texts = [getattr(m, "content", "") for m in messages] + assert any("hello gcode" in str(t) for t in texts) + assert any("README 只有一句" in str(t) for t in texts) + assert llm.calls == 2 + + +@pytest.mark.asyncio +async def test_replace_denied_file_unchanged(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + target = tmp_path / "app.py" + target.write_text("print(1)\n", encoding="utf-8") + safety = SafetyGate(_never) + tools = make_fs_tools(safety, tmp_path) + llm = ScriptedLLM( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "replace_in_file", + "args": { + "path": "app.py", + "old_text": "print(1)", + "new_text": "print(2)", + }, + "id": "call_w", + "type": "tool_call", + } + ], + ), + AIMessage(content="用户拒绝了写入,我改用其它办法。"), + ] + ) + agent = Agent(llm, tools, cwd=tmp_path) + messages = await agent.invoke_turn("把 print(1) 改成 print(2)") + joined = "\n".join(str(getattr(m, "content", "")) for m in messages) + assert "用户拒绝本次写入" in joined + assert target.read_text(encoding="utf-8") == "print(1)\n" + assert llm.calls == 2 diff --git a/tests/test_grep.py b/tests/test_grep.py new file mode 100644 index 0000000..9d80b3c --- /dev/null +++ b/tests/test_grep.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import pytest + +from gcode.safety import SafetyGate +from gcode.tools.fs import make_fs_tools + + +def _grep(tmp_path: Path, **kwargs): + tools = {t.name: t for t in make_fs_tools(SafetyGate(), tmp_path)} + return tools["grep"].invoke(kwargs) + + +def test_grep_finds_line(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.py").write_text("def init_agent():\n return 1\n", encoding="utf-8") + out = _grep(tmp_path, query="init_agent") + assert "src" in out and "app.py" in out + assert ":1:" in out + assert "init_agent" in out + + +def test_grep_skips_git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / ".git").mkdir() + (tmp_path / ".git" / "HEAD").write_text("init_agent", encoding="utf-8") + (tmp_path / "ok.py").write_text("x = 1\n", encoding="utf-8") + out = _grep(tmp_path, query="init_agent") + assert "No matches" in out + assert ".git" not in out + + +def test_grep_rejects_outside(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + out = _grep(tmp_path, query="foo", path="..") + assert "已拒绝" in out or "Error" in out + + +def test_grep_glob_filters(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "a.py").write_text("needle\n", encoding="utf-8") + (tmp_path / "a.txt").write_text("needle\n", encoding="utf-8") + out = _grep(tmp_path, query="needle", glob="*.py") + assert "a.py" in out + assert "a.txt" not in out diff --git a/tests/test_path_sandbox.py b/tests/test_path_sandbox.py new file mode 100644 index 0000000..f2ef6d3 --- /dev/null +++ b/tests/test_path_sandbox.py @@ -0,0 +1,52 @@ +from pathlib import Path + +import pytest + +from gcode.tools.fs import resolve_path + + +def test_relative_inside_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "a.txt").write_text("x", encoding="utf-8") + assert resolve_path("a.txt") == (tmp_path / "a.txt").resolve() + + +def test_rejects_parent_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="已拒绝"): + resolve_path("..") + + +def test_rejects_absolute_outside(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + outsider = tmp_path.parent / "outside-gcode-test.txt" + with pytest.raises(ValueError, match="已拒绝"): + resolve_path(str(outsider)) + + +def test_allows_user_home_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from gcode.config import get_home_dir + + proj = tmp_path / "proj" + proj.mkdir() + monkeypatch.chdir(proj) + home = get_home_dir() + home.mkdir(parents=True, exist_ok=True) + target = home / "note.txt" + target.write_text("ok", encoding="utf-8") + assert resolve_path(str(target), proj) == target.resolve() + + +def test_list_dir_skips_git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from gcode.safety import SafetyGate + from gcode.tools.fs import make_fs_tools + + monkeypatch.chdir(tmp_path) + (tmp_path / ".git").mkdir() + (tmp_path / "src").mkdir() + (tmp_path / "readme.txt").write_text("x", encoding="utf-8") + tools = {t.name: t for t in make_fs_tools(SafetyGate(), tmp_path)} + out = tools["list_dir"].invoke({"path": "."}) + assert "readme.txt" in out + assert "src" in out + assert ".git" not in out diff --git a/tests/test_safety.py b/tests/test_safety.py new file mode 100644 index 0000000..eec44a6 --- /dev/null +++ b/tests/test_safety.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from gcode.safety import REJECT_SHELL, REJECT_WRITE, SafetyGate, unified_diff +from gcode.tools.fs import make_fs_tools +from gcode.tools.shell import make_shell_tools + + +def test_unified_diff_contains_change() -> None: + diff = unified_diff("a.py", "print(1)\n", "print(2)\n") + assert "-print(1)" in diff + assert "+print(2)" in diff + + +@pytest.mark.asyncio +async def test_unique_replace_and_reject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + path = tmp_path / "a.py" + path.write_text("foo\nfoo\n", encoding="utf-8") + + async def deny(kind: str, detail: str) -> bool: + return False + + tools = {t.name: t for t in make_fs_tools(SafetyGate(deny), tmp_path)} + out = await tools["replace_in_file"].ainvoke( + {"path": "a.py", "old_text": "foo", "new_text": "bar"} + ) + assert "出现了 2 次" in out + assert path.read_text(encoding="utf-8") == "foo\nfoo\n" + + path.write_text("only-once\n", encoding="utf-8") + out = await tools["replace_in_file"].ainvoke( + {"path": "a.py", "old_text": "only-once", "new_text": "twice"} + ) + assert REJECT_WRITE in out + assert path.read_text(encoding="utf-8") == "only-once\n" + + +@pytest.mark.asyncio +async def test_shell_denied_does_not_run() -> None: + async def deny(kind: str, detail: str) -> bool: + return False + + tools = {t.name: t for t in make_shell_tools(SafetyGate(deny))} + out = await tools["execute_command"].ainvoke({"command": "echo should-not-run"}) + assert REJECT_SHELL in out diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..3482b56 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,9 @@ +from pathlib import Path + +from gcode.session import load_thread_id, save_thread_id + + +def test_thread_id_persists(tmp_path: Path) -> None: + save_thread_id("thread-abc", tmp_path) + assert load_thread_id(tmp_path) == "thread-abc" + assert (tmp_path / ".gcode" / "session.json").is_file() diff --git a/tests/test_windows.py b/tests/test_windows.py new file mode 100644 index 0000000..f335bfc --- /dev/null +++ b/tests/test_windows.py @@ -0,0 +1,19 @@ +from gcode.windows import decode_bytes, truncate_output + + +def test_decode_utf8() -> None: + assert decode_bytes("你好".encode("utf-8")) == "你好" + + +def test_decode_gbk_fallback() -> None: + assert decode_bytes("你好".encode("gbk")) == "你好" + + +def test_decode_empty() -> None: + assert decode_bytes(b"") == "" + + +def test_truncate() -> None: + text = "a" * 100 + assert truncate_output(text, limit=50).endswith("字符]") + assert "a" * 20 == truncate_output("a" * 20, limit=50) diff --git a/tui-preview.svg b/tui-preview.svg new file mode 100644 index 0000000..3ff9b91 --- /dev/null +++ b/tui-preview.svg @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + G-CODE + + + + + + + + + + G-CODE — agicto/deepseek-v4-flash +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +G-CODE 已就绪。用中文下任务即可。思考 +先读 README,再总结要点。 +浏览器工具不可用:未检测到 Node.js / npx。请安装 Node.js 后重试。 + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +请总结当前目录 README + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +README 说明这是 Windows 上的个人 Coding Agent。主界面是 TUI,经典 CMD 会回退 REPL。 + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +工具 +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +read_file   README.md +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + agicto/deepseek-v4-flash  |  session a1b2c3d4  |  browser off  |  /help 帮助  |  /edit 外部编辑器  |  /clear 新会话  |  /model 选模型 +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +入任务,/help 查看命令 +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + diff --git a/项目资料/01-架构与调用链.md b/项目资料/01-架构与调用链.md new file mode 100644 index 0000000..b83d5b4 --- /dev/null +++ b/项目资料/01-架构与调用链.md @@ -0,0 +1,175 @@ +# 01 架构与调用链 + +对照源码:`D:\ideaProject\QozeCode-main` + +## 1. 它到底是什么 + +QozeCode 是一个**命令行里的 Coding Agent**。用户在终端打字,模型按 ReAct 循环自己决定要不要读文件、跑命令、搜网页;过程实时画在 Textual TUI 上。 + +它不是 IDE 插件,也不是 Web 对话页。核心就三件事: + +- 把“对话状态”交给 LangGraph 管 +- 把“动手能力”做成 LangChain Tool +- 把“思考 / 工具 / 回复”流式渲染到终端 + +## 2. 分层(从外到内) + +```text +┌─────────────────────────────────────────────────────────┐ +│ 启动层 launcher.py + qoze_tui.py:main() │ +│ 选模型、读配置、拉起 Textual App │ +├─────────────────────────────────────────────────────────┤ +│ 交互层 qoze_tui.py + tui_components/ │ +│ 斜杠命令、流式渲染、思考区、工具状态条 │ +├─────────────────────────────────────────────────────────┤ +│ 决策层 qoze_code_agent.py │ +│ LangGraph: llm_call ⇄ tool_node │ +├─────────────────────────────────────────────────────────┤ +│ 模型层 model_initializer.py + config_manager.py │ +│ ChatOpenAI 等,兼容 OpenAI 协议的自定义 base_url │ +├─────────────────────────────────────────────────────────┤ +│ 能力层 │ +│ tools/ 文件、命令、搜索 │ +│ skills/ 专家知识注入 prompt │ +│ qoze_mcp/ 外部 MCP 工具热加载 │ +│ tools/subagent_tool.py 子图并行 │ +└─────────────────────────────────────────────────────────┘ +``` + +## 3. 目录对照(只记主干) + +| 路径 | 职责 | 改写时优先级 | +|------|------|----------------| +| `qoze_tui.py` | Textual App,用户输入入口 | 第二阶段再写,MVP 可用纯 CLI | +| `launcher.py` | 启动选模型 | 可做成 `--model` 参数 | +| `qoze_code_agent.py` | **心脏**:State、图、checkpoint | 第一阶段必须吃透并重写 | +| `model_initializer.py` | 按厂商创建 LLM | 先只留 DeepSeek / OpenAI 兼容 | +| `config_manager.py` | 读 ini 配置和密钥 | 先 YAML/ENV 即可 | +| `enums.py` | Provider / ModelType | 自己项目用简单字符串就行 | +| `utils/system_prompt.py` | 静态 prompt + 动态上下文 | 必须理解,缓存设计值得学 | +| `tools/` | 所有 Function Calling 工具 | 先 4 个:读文件、改文件、跑命令、列目录 | +| `tools/subagent_tool.py` | 迷你版主图 | 第三阶段 | +| `skills/` | SKILL.md 发现与激活 | 第三阶段 | +| `qoze_mcp/` | MCP 配置、激活、工具注入 | 第三阶段 | +| `tui_components/` | 气泡、流式、thinking widget | 第二阶段 | +| `utils/git_context.py` | 把 git status/diff 塞进 prompt | 增强阶段很有用 | + +## 4. 启动调用链 + +```text +qoze / python qoze_tui.py + │ + ├─ launcher.ensure_config() 没有配置就写模板 + ├─ get_model_choice() 交互选模型(可用 --model 跳过) + └─ Qoze(provider, model_type).run() + │ + ├─ initialize_llm() 得到 ChatOpenAI 实例 + ├─ llm.bind_tools(tools) + ├─ init_agent() SQLite checkpointer 编译 StateGraph + └─ 用户回车 + └─ process_user_input() +``` + +TUI 里以 `/` 开头的是**本地命令**,不进图:`/clear`、`/skills`、`/mcp`、`/quit`、`/checkpoint`。 +普通自然语言才 `agent.astream(...)`。 + +## 5. 一轮对话怎么走完(必须能默画) + +主图在 `qoze_code_agent.py`: + +```text +START + │ + ▼ +llm_call + │ 拼 SystemMessage(静态 prompt) + │ + 动态上下文(目录树、git、规则、技能) + │ + 历史 messages + │ → await llm_with_tools.ainvoke(...) + │ + ├─ 有 tool_calls ──► tool_node ──► 再回到 llm_call + │ │ + │ └─ 多个 tool_call 用 asyncio.gather 并行 + │ + └─ 没有 tool_calls ──► END +``` + +对应代码: + +- 状态:`MessagesState`(`messages` 用 `operator.add` 追加) +- 节点:`llm_call`、`tool_node` +- 边:`should_continue` / `should_continue_from_tool` +- 持久化:`AsyncSqliteSaver`,库在当前项目 `.qoze/data/checkpoints.db` + +这就是 ReAct:**Reason(llm_call)→ Act(tool_node)→ Observe(ToolMessage 写回)→ 再 Reason**。 + +## 6. Prompt 怎么拼(这是质量关键) + +`llm_call` 每次都会重算上下文,但拆成两段: + +| 段 | 放哪 | 内容 | 为什么 | +|----|------|------|--------| +| 静态 | `SystemMessage` | 角色、工具用法、行为规范 | 方便 Prompt Caching | +| 动态 | 第一条 `HumanMessage` 前面 | 系统信息、工作目录树、git、`.qoze/rules`、已激活 skill、memory | 每次会变,不能进静态段 | + +还要处理两件脏活: + +- 模型不支持视觉时,剥掉 `image_url` +- checkpoint 恢复后,assistant 有 `tool_calls` 但缺 `ToolMessage`,要补占位,否则 OpenAI 兼容接口会 400 + +## 7. 工具层真实情况(以代码为准) + +当前挂在主 Agent 上的核心工具: + +- `execute_command`:shell(Windows 也能跑,但没有 Linux 的进程组杀法) +- `read_file` / `list_files` / `search_in_files` / `grep_file` / `find_files` / `replace_in_file` +- `tavily_search` / `read_url` +- skill 四个:activate / list / deactivate / install guide +- MCP 四个:list / activate / deactivate / install guide +- `dispatch_subagent` +- `analyze_project` / `find_symbols` / `trace_imports` +- `transcribe_audio` / `get_current_datetime` + +注意: + +- `write_file` **被注释了**,写文件靠 `replace_in_file` 或 `execute_command` +- `browser_*` **整段注释**,浏览器走 MCP(`chrome-devtools`) +- README 的 `/plan` 在这份代码里**没有对应实现**,别按文档去找 + +路径安全:`file_tools._resolve_under_cwd` 只允许 cwd 和 `~/.qoze/`,这是改写时必须保留的约束。 + +## 8. 三条插件通道(先分清,再决定要不要抄) + +```text +Skill 发现 SKILL.md → 用户 activate → 把文档塞进动态 prompt +MCP 读 mcp_config.json → 拉起外部进程 → 把远端 tools 注入 tools_by_name +Subagent 再建一张同样的小图,工具更少、无 checkpoint、上下文隔离 +``` + +记忆口诀: + +- Skill = **知识**(改 prompt) +- MCP = **外来工具**(改 tool 列表) +- Subagent = **再开一个大脑**(改执行拓扑) + +## 9. TUI 流式 + +`tui_components/messages/stream_handler.py` 订阅 `agent.astream`: + +- 模型 `reasoning_content` → `ThinkingWidget` +- 正文 token → `BotMessageWidget`(增量 buffer + 调度器,避免刷爆终端) +- 出现 tool_call → 工具状态面板 +- ToolMessage 回来 → 标记完成/失败 + +自己复刻时,第一版可以不流式,等图跑完再 `print`。流式是体验,不是 Agent 正确性。 + +## 10. 和宣传文案不一致的地方(精读避坑) + +| README 说法 | 这份源码 | +|-------------|----------| +| `/plan` 三份文档 | 未见实现 | +| Playwright 浏览器工具 | `browser_tool.py` 注释,改 MCP | +| 语音 | 有 `AudioManager` / Soniox,Windows 基本不可用 | +| 内置 skills 一堆 | 仓库 `skills/` 只有管理器,内置技能目录可能不在这份拷贝里 | + +精读原则:**调用链以 `qoze_code_agent.py` + `qoze_tui.py` 为准。** diff --git a/项目资料/02-知识点地图.md b/项目资料/02-知识点地图.md new file mode 100644 index 0000000..38ed7be --- /dev/null +++ b/项目资料/02-知识点地图.md @@ -0,0 +1,145 @@ +# 02 知识点地图 + +每个知识点都绑到源码。学的时候合上文档,能用自己的话讲出来,才算过关。 + +## A. Agent 核心(必须牢固) + +### A1. ReAct + +- 概念:Thought → Action → Observation 循环,直到模型不再调工具 +- 源码:`qoze_code_agent.py` 的 `llm_call` / `tool_node` / `should_continue` +- 过关标准:不看代码画出状态图;能解释“为什么 tool 之后必须再进 llm_call” + +### A2. LangGraph StateGraph + +- 概念:节点函数返回要**更新**的 state 字段,不是替换整个对象 +- `messages: Annotated[list, operator.add]` 表示每次 append +- `compile(checkpointer=...)` 让同一 `thread_id` 能恢复对话 +- 源码:`MessagesState`、`agent_builder`、`init_agent()` +- 过关标准:自己用 30 行写出同等的两节点图 + +### A3. Tool Calling(Function Calling) + +- 概念:模型不直接“执行 Python”,它只输出 `tool_calls` JSON;运行时按名字找到函数再跑 +- LangChain:`@tool` 从类型注解和 docstring 生成 schema;`llm.bind_tools(tools)` +- 源码:`tools/*.py` 的 `@tool`;`tool_node` 里 `tools_by_name[name].ainvoke(args)` +- 过关标准:自己写一个 `read_file` 工具,让模型真的读到磁盘内容 + +### A4. 消息协议 + +必须分清四种消息,否则 400 错误会搞很久: + +| 类型 | 谁产生 | 作用 | +|------|--------|------| +| SystemMessage | 你 | 角色与规则 | +| HumanMessage | 用户 | 任务 | +| AIMessage | 模型 | 回复或 tool_calls | +| ToolMessage | 你的运行时 | 把工具结果交回,必须带 `tool_call_id` | + +源码:`_repair_incomplete_tool_calls` + +### A5. Prompt Caching 拆分 + +- 静态放 System,动态放 User 前缀 +- 源码:`utils/system_prompt.py` 的 `get_static_system_prompt` / `get_dynamic_context` +- 过关标准:能说出“目录树为什么不能放进 SystemMessage” + +## B. 模型接入(结合你现在的环境) + +### B1. OpenAI 兼容协议 + +- `base_url` + `api_key` + `model` 三件套 +- 你已经在用 `https://api.agicto.cn/v1` + DeepSeek +- 源码:`model_initializer.py` 的 `ChatOpenAI(...)` +- 过关标准:10 行脚本能 `ainvoke` 通;再 `bind_tools` 通 + +### B2. 推理模型的 thinking + +- DeepSeek 一类会在 delta 里带 `reasoning_content` +- 源码:`model_initializer.patch_langchain_openai` +- TUI:`ThinkingWidget` +- 过关标准:知道 thinking 不是最终答案,不要当 assistant content 存 + +### B3. 多厂商适配 + +- 这是 Qoze 变复杂的主因:Vertex、Kimi、智谱、Qwen 各写一套 +- 改写建议:第一版只保留 **OpenAI 兼容** 一个适配器 + +## C. 工具与安全 + +### C1. 文件工具 + +- 源码:`tools/file_tools.py` +- 要点:路径限制在 cwd;读文件带行号范围;改文件用精确替换而不是整文件覆盖 +- 过关标准:能讲清为什么 `write_file` 危险、`replace_in_file` 更稳 + +### C2. Shell 工具 + +- 源码:`tools/execute_command_tool.py` +- 要点:`create_subprocess_shell`、超时、stdout/stderr 合并 +- 风险:模型可以 `rm -rf` / 删盘;改写时要加**白名单或人工确认** + +### C3. 并行 tool_calls + +- 一轮里模型可能同时调多个工具 +- 源码:`asyncio.gather` in `tool_node` +- 过关标准:知道“读两个文件可以并行,写同一文件不能瞎并行” + +## D. 工程结构(决定你改写时像不像业余) + +### D1. 配置与密钥 + +- 源码:`config_manager.py`,Windows 在 `%APPDATA%\qoze\qoze.conf` +- 原则:密钥不进仓库 + +### D2. Checkpoint / 会话 + +- SQLite + `thread_id` +- `/clear` 会换新 uuid 并清库 +- 源码:`init_agent` / `save_thread_id` / `qoze_tui.py` 的 `clear` + +### D3. 规则注入 + +- `.qoze/rules/*.md` 每次动态塞进 prompt +- 这就是 Cursor 的 `.cursor/rules` 同类机制,非常值得学 + +## E. 插件三件套(理解即可,MVP 不做) + +### E1. Skills + +- 发现路径:项目 `.qoze/skills` > 用户 `~/.qoze/skills` > 内置 +- 激活后把 SKILL.md 正文注入动态上下文 +- 源码:`skills/skill_manager.py` + +### E2. MCP + +- 配置里写 `command + args`,stdio 拉起外部 server +- 激活后 `get_active_tools()` 合并进 `tools_by_name` +- 源码:`qoze_mcp/mcp_manager.py`、`tools/mcp_tools.py` + +### E3. Subagent + +- 再 compile 一张无 checkpointer 的图 +- 工具子集,禁止再 dispatch,避免套娃 +- 源码:`tools/subagent_tool.py` 的 `_build_subagent` + +## F. TUI(体验层,可后置) + +| 概念 | 源码 | 你要懂什么 | +|------|------|------------| +| Textual App | `qoze_tui.py` 的 `class Qoze(App)` | compose / 事件 / 异步任务 | +| 流式增量 | `display_buffer.py` + `stream_scheduler.py` | 不要每个 token 都 refresh | +| 思考区 | `thinking_widget.py` | 和正文拆开 | +| 终端兼容 | `terminal_compat.py` | Windows GBK / 旧终端会炸 Unicode | + +Windows 坑你已经踩过:Rich 默认 legacy_windows 会 GBK 编码失败。自己项目一开始就 `PYTHONUTF8=1`,TUI 用 Windows Terminal。 + +## G. 建议的练习题(每题半天到一天) + +1. 不用 LangGraph,手写一个 while 循环 ReAct(messages 列表自己维护) +2. 用 LangGraph 把上面的循环换成两节点图 +3. 给图加上 `read_file` + `execute_command`,完成“读 README 并总结” +4. 给图加上 SQLite checkpointer,重启进程还能续聊 +5. 把 `astream` 的 token 打到终端(先 print,再考虑 Textual) +6. 实现一个 skill:激活后模型会按你的 Java 代码规范改代码 +7. 实现一个危险命令拦截:`execute_command` 前打印 diff/命令并等 y/N diff --git a/项目资料/03-学习与改写计划.md b/项目资料/03-学习与改写计划.md new file mode 100644 index 0000000..fce81b5 --- /dev/null +++ b/项目资料/03-学习与改写计划.md @@ -0,0 +1,133 @@ +# 03 学习与改写计划 + +总原则:**先能讲清楚,再能写出来,最后才加功能。** +不要边读边把整个 Qoze 复制到新仓库。 + +建议节奏:每天 1~2 小时,大约 8~10 周。可以按周压缩,但不要跳过“过关标准”。 + +--- + +## 阶段 0|建立实验场(0.5 天) + +- 继续用现有环境跑 Qoze:`deepseek-v4-flash` +- 自己下 5 个任务观察行为,并记笔记: + 1. “当前目录有哪些 py 文件” + 2. “读 README 前 80 行并总结” + 3. “用 grep 找 init_agent 在哪” + 4. “把某某函数加一行注释”(看它怎么改文件) + 5. “执行 `python --version`” +- 每次记下:调了哪些工具、调了几轮、哪一步走歪了 + +产出:本目录下自建 `笔记/观察日志.md`(自己写,不要求一次完美) + +过关:能口述一轮对话的节点顺序 + +--- + +## 阶段 1|精读心脏(第 1~2 周) + +精读顺序(严格按此,不要先扎进 TUI): + +1. `enums.py` + `config_manager.py` + `model_initializer.py`(知道模型怎么来的) +2. `qoze_code_agent.py` 全文,重点 `MessagesState` 到 `init_agent` +3. `utils/system_prompt.py` +4. `tools/file_tools.py` + `tools/execute_command_tool.py` +5. `qoze_tui.py` 的 `process_user_input`(只看命令分发和 astream 入口) + +配套练习:完成知识点地图里的练习 1~4。 + +过关标准: + +- 画出主图 mermaid,不看文档 +- 解释 `operator.add`、`thread_id`、缺失 ToolMessage 各是什么问题 +- 能指出 README 里有哪些功能这份代码其实没有 + +--- + +## 阶段 2|精读外壳(第 3 周) + +1. `tui_components/messages/stream_handler.py` +2. `thinking_widget.py` / `tool_status_panel.py` +3. `skills/skill_manager.py` + `tools/skill_tools.py` +4. `qoze_mcp/` + `tools/mcp_tools.py` +5. `tools/subagent_tool.py` 的 `_build_subagent` + +过关标准:用三句话区分 Skill / MCP / Subagent +此阶段**仍不开始大改自己的项目结构**,只做小实验。 + +--- + +## 阶段 3|最小复刻(第 4~6 周)★ 真正开始“自己的项目” + +新仓库建议路径(示例):`D:\myProgram\Learning\ai-coding改写\my-agent` +新名字自己定,不要叫 Qoze。 + +只做这些: + +```text +用户 CLI 输入 + → OpenAI 兼容 LLM(你的 Agicto + DeepSeek) + → LangGraph ReAct + → 工具:read_file / replace_in_file / list_dir / execute_command(带确认) + → 终端打印思考摘要 + 工具日志 + 最终回答 + → SQLite 保存会话 +``` + +详细文件清单见 `04-最小复刻清单.md`。 + +验收: + +- 在任意代码目录启动,能完成“读文件 → 改一处 → 跑命令看结果” +- 重启进程,同一 session 还能接着聊 +- 危险命令会停下来问你 +- 你能 15 分钟给别人讲完架构 + +--- + +## 阶段 4|补齐体验(第 7~8 周) + +按需加,**加一个就停下来用一周**: + +1. 流式输出(先 Rich Live,再考虑 Textual) +2. `/clear` `/help` 斜杠命令 +3. `.myagent/rules/` 规则注入 +4. 一个 Skill(例如 `java-style`) +5. git status 进动态上下文 + +不要同时开工 MCP 和 Subagent。 + +--- + +## 阶段 5|差异化(第 9 周以后) + +只从 `05-差异化增强方向.md` 里挑 **1 个主特色 + 1 个小增强**。 + +结合你的日常,优先候选: + +- 主特色:公司 GitLab / Jenkins 只读查询工具(列 MR、看构建状态) +- 小增强:Windows 终端编码和 Java/Maven 项目分析 + +做完主特色再考虑 Subagent、MCP 浏览器。 + +--- + +## 每周固定动作 + +| 星期 | 做什么 | +|------|--------| +| 前 4 天 | 精读或写代码,每天结束写 10 行笔记 | +| 第 5 天 | 给自己做一次“口述架构”或给 Agent 下真实任务 | +| 周末 | 对照过关标准打勾;没过的下周重复,不硬开下一阶段 | + +## 明确不要做的事 + +- 不要一上来复刻整个 TUI +- 不要把 10 家模型供应商抄进自己项目 +- 不要在没路径沙箱的情况下开放 `execute_command` +- 不要把 API Key 写进新仓库 +- 不要一边复制 Qoze 文件一边改名,那叫换皮,学不到东西 + +## 和源码许可证 + +QozeCode 是 Apache 2.0。若大量复制原文件,需要保留版权头和 NOTICE。 +推荐路径:看懂后**自己重写**同构实现,只借鉴状态图和分层,不复制 UI 文案与品牌。 diff --git a/项目资料/04-最小复刻清单.md b/项目资料/04-最小复刻清单.md new file mode 100644 index 0000000..98fd26a --- /dev/null +++ b/项目资料/04-最小复刻清单.md @@ -0,0 +1,115 @@ +# 04 最小复刻清单 + +目标:一个你能完全讲清楚的小 Agent,不是功能全集。 + +建议包名 / 命令:自己定,下文用 `myagent` 指代。 + +## 1. 目录骨架 + +```text +my-agent/ + pyproject.toml + README.md + src/myagent/ + __init__.py + cli.py # argparse:--model / --session + config.py # 读环境变量或用户目录 ini/yaml + llm.py # 只创建 ChatOpenAI(base_url, api_key, model) + prompt.py # 静态 system + 动态 cwd/tree + graph.py # StateGraph:llm_call / tool_node + tools/ + fs.py # read_file, list_dir, replace_in_file + shell.py # execute_command + 确认回调 + session.py # sqlite checkpointer + tests/ + test_graph_smoke.py + test_path_sandbox.py +``` + +第一版**不要**:textual、mcp、skills、subagent、语音、多厂商。 + +## 2. 配置(最小) + +环境变量即可: + +```text +MYAGENT_API_KEY=... +MYAGENT_BASE_URL=https://api.agicto.cn/v1 +MYAGENT_MODEL=deepseek-v4-flash +``` + +密钥只放用户目录或系统环境,禁止进 git。 + +## 3. 图的最小实现要点 + +```text +State = { messages, llm_calls } + +START → llm_call → (有 tool_calls?) + 是 → tool_node → llm_call + 否 → END +``` + +硬性要求: + +- 工具异常要变成 ToolMessage 文本,不能把整个图打崩 +- 一轮多个 tool_calls 可以先串行,跑通再并行 +- `thread_id` 用参数传入,默认 `default` + +## 4. 工具验收 + +| 工具 | 必须有的约束 | 怎么测 | +|------|----------------|--------| +| read_file | 只能读 cwd 内;限制行数 | 读 `../` 应拒绝 | +| list_dir | 跳过 `.git` / `node_modules` / `.venv` | 在本项目根目录跑 | +| replace_in_file | old_text 必须唯一匹配 | 改一个函数名再读回来 | +| execute_command | 超时;打印命令等 y/N | 输入 `n` 应取消 | + +## 5. CLI 体验(第一版足够) + +```text +$ myagent +> 请总结当前目录 README +[think] ... +[tool] read_file README.md +[final] ... + +> /clear +> /exit +``` + +没有气泡也没关系。日志清晰比好看重要。 + +## 6. 测试底线 + +- `test_path_sandbox.py`:逃出 cwd 失败 +- `test_graph_smoke.py`:mock LLM 返回一次 tool_call 再返回纯文本,断言 tool 被调用、最终 END + +没有 mock 测试,不要进入阶段 4。 + +## 7. 对照 Qoze 时允许借鉴的“形状” + +可以借鉴: + +- 两节点 ReAct 图 +- 静态/动态 prompt 分离 +- 文件路径沙箱 +- checkpoint 的 thread_id 思路 + +不要复制: + +- 整份 `system_prompt.py` 长文案 +- Textual CSS 和像素 logo +- 十几个厂商的 `initialize_llm` 分支 +- 被注释掉的浏览器工具堆 + +## 8. 完成定义(Definition of Done) + +当下面全是“是”,阶段 3 才结束: + +- [ ] 新仓库能独立 `pip install -e .` 跑起来 +- [ ] 只用 DeepSeek 兼容接口 +- [ ] 四件套工具都有单测或手工脚本 +- [ ] 会话可恢复 +- [ ] README 是你自己写的架构说明(不是拷来的) +- [ ] 你能不看代码画出数据流 diff --git a/项目资料/05-差异化增强方向.md b/项目资料/05-差异化增强方向.md new file mode 100644 index 0000000..d51ddfd --- /dev/null +++ b/项目资料/05-差异化增强方向.md @@ -0,0 +1,89 @@ +# 05 差异化增强方向 + +Qoze 已经是“通用 Coding Agent + TUI + Skill/MCP/Subagent”。 +你要做成自己的项目,价值不在再做一个更全的 Qoze,而在**更适合你日常的那几刀**。 + +阶段 5 只选 **1 个主特色**。下面按“值得做 / 以后再说 / 不要做”分组。 + +## 主特色候选(选一) + +### 1. 公司研发工具箱(最贴你的工作) + +把内部流程做成工具,而不是让模型瞎猜命令: + +- 只读:GitLab 当前用户的 MR 列表、某仓库流水线状态 +- 只读:Jenkins 最近一次构建结果(你已有相关 skill 和工作流) +- 写操作必须二次确认:创建 MR、触发构建 + +为什么这是差异化:通用 Agent 不会懂 hjgit / 公司 CI;这是你的护城河。 + +注意:密钥走环境变量;工具默认只读;日志不要打印 token。 + +### 2. Java / Maven 项目大脑 + +Qoze 的 `analyze_project` / `find_symbols` 偏通用。你可以做深: + +- 识别 `pom.xml` 模块结构 +- `mvn -q -DskipTests compile` 封装成工具(带确认) +- 失败日志截断后回传模型 +- 可选:`jar tf` + 定位依赖冲突(README 提过,源码未必完整) + +适合你长期改 Java 仓库。 + +### 3. 人在回路(HITL)安全层 + +把 `execute_command` 和文件写入做成审批: + +```text +模型提议 + → 终端展示命令 / diff + → y 执行 / n 拒绝并写回 ToolMessage“用户拒绝” + → 模型改方案 +``` + +这会立刻比很多开源 Agent 更适合在公司电脑上用。 + +### 4. Windows 第一公民 + +Qoze 在 Windows 上有编码、进程组、语音、crontab 一堆坑。你可以: + +- 启动强制 UTF-8 +- PowerShell 作为默认 shell +- 路径、杀进程按 Win32 做对 +- 文档只承诺 Windows Terminal + +范围小、但每天都有体感。 + +## 小增强(主特色稳定后再加) + +- `.myagent/rules/` 项目规则(学 `.qoze/rules`) +- git 上下文注入(学 `utils/git_context.py`) +- 一个 Skill:`java-style` 或 `commit-message` +- 流式输出 + 简单思考区(不必上完整 Textual) +- `/compact`:超长对话摘要后截断,控制 token + +## 以后再说 + +- MCP(等你真有一个外部 server 要接) +- Subagent 并行(等单 Agent 经常上下文爆炸) +- 浏览器自动化 +- 语音 +- 多模型供应商矩阵 + +这些是 Qoze 变复杂的来源。你没需求就不要搬。 + +## 不要做 + +- 把品牌、ASCII logo、文案换个名发布 +- 一上来做 Web 版 + TUI 版 + VSCode 插件三端 +- 在工具里内置高危操作且无确认(删库、改系统目录、提交并 push) +- 把 Key 写进示例配置提交到 git + +## 建议的最终形态(3 个月后) + +一句话产品定义,写进你自己的 README 开头: + +> 一个跑在 Windows 终端、默认 DeepSeek、对 Java 仓库和公司 CI 友好的个人 Coding Agent; +> 改文件和跑命令前会让我确认;架构我能自己讲清楚。 + +如果这句你改不动,说明特色还没选明白,先不要写代码。 diff --git a/项目资料/README.md b/项目资料/README.md new file mode 100644 index 0000000..8f18bc9 --- /dev/null +++ b/项目资料/README.md @@ -0,0 +1,39 @@ +# QozeCode 学习与改写档案 + +这套文档的目标不是“照抄一个开源 Agent”,而是: + +1. **读懂** QozeCode 为什么这样拆、数据怎么流、每一层在解决什么问题 +2. **练熟** ReAct / LangGraph / Tool Calling / 流式 TUI 这几块硬知识点 +3. **改写成自己的项目**(新名字、新结构、自己能讲清楚的代码) +4. **再往上加自己的东西**(公司场景、Windows 体验、审批、内部工具) + +源码位置:`D:\ideaProject\QozeCode-main` +许可证:Apache 2.0(可以基于它做自己的项目,但**不要**直接换皮发布;改写时建议“架构借鉴 + 代码重写”) + +## 怎么用这套文档 + +按这个顺序读,不要跳着抄: + +| 顺序 | 文件 | 用途 | +|------|------|------| +| 1 | [01-架构与调用链.md](./01-架构与调用链.md) | 先建立全局图:模块、启动、一轮对话怎么走完 | +| 2 | [02-知识点地图.md](./02-知识点地图.md) | 每个模块对应要掌握的概念、该看哪段源码 | +| 3 | [03-学习与改写计划.md](./03-学习与改写计划.md) | 分阶段时间表:精读 → 最小复刻 → 增强 | +| 4 | [04-最小复刻清单.md](./04-最小复刻清单.md) | 自己项目的 MVP 文件清单和验收标准 | +| 5 | [05-差异化增强方向.md](./05-差异化增强方向.md) | 读懂之后往上加什么,避免做成第二个 Qoze | +| 6 | [开发方案/](./开发方案/README.md) | G-CODE 工程方案:产品决策、Windows 输入、架构、分期 | + +G-CODE 源码目录(尚未建库):`D:\ideaProject\gcode` + +## 一句话定位 + +QozeCode = **LangGraph 上的 ReAct 循环** + **给模型的一篮子工具** + **Textual 终端聊天气泡** + **Skill / MCP / Subagent 三套插件层**。 + +README 里写得很满(Plan 模式、浏览器、语音)。当前这份源码里,有些能力已经注释或未落地。精读时以**代码为准**,宣传文案只当“产品愿景”。 + +## 建议的改写原则 + +- 先跑通「用户一句话 → 模型思考 → 调工具 → 再思考 → 回答」 +- 每一层都自己写一遍,能向别人讲清楚 +- 不追求一开始就有 TUI、MCP、Subagent +- 公司场景(GitLab / Jenkins / Java)放到增强阶段,不要一开始就塞进去 diff --git a/项目资料/开发方案/00-产品定义.md b/项目资料/开发方案/00-产品定义.md new file mode 100644 index 0000000..5806a19 --- /dev/null +++ b/项目资料/开发方案/00-产品定义.md @@ -0,0 +1,55 @@ +# 00 产品定义 + +## 一句话 + +G-CODE 是跑在 Windows 上的个人 Coding Agent:用户在终端里下任务,模型按 ReAct 循环读文件、改文件、跑命令、必要时开浏览器;过程画在自研 TUI 上。经典 CMD 无法承载 TUI 中文输入时,自动回退 REPL,保证还能干活。 + +## 它不是什么 + +- 不是 QozeCode 换皮 +- 不是 IDE 插件,也不是 Web 对话页 +- 不是「十家模型 + 语音 + 菜单栏」的功能全集 + +## 两条路径 + +| 路径 | 作用 | +|------|------| +| 参考 | `D:\ideaProject\QozeCode-main` | +| 实现 | `D:\ideaProject\gcode` | +| 方案 | 本目录 | + +## 已拍板 + +| 决策 | 选择 | +|------|------| +| 主界面 | 自研 Textual TUI。模块:消息列表、思考区、工具状态、底部输入。观感可与 Qoze 大差不差,品牌 / CSS / 文案自己写 | +| Windows 输入 | **方案 C**:能跑 TUI 的终端开 TUI;conhost / 经典 cmd **拒绝 TUI**,提示改用 Windows Terminal,并自动进入 REPL | +| cmd 里 TUI 打中文 | **不作为验收项**(业界基本做不到) | +| 多模型 | yaml 注册表;一期只实现 `openai_compatible`(DeepSeek / 千问 / 智谱 / Kimi / Azure 兼容 / LiteLLM / Agicto) | +| 浏览器 | 只做 Microsoft `@playwright/mcp`。无 Node 则提示不可用,不做 Python Playwright 备选 | +| 人在回路 | 写文件展示 diff 后 y/N;跑命令展示命令后 y/N。拒绝则写回 ToolMessage,模型改方案 | +| 配置根 | 单一 home:Windows 用 `%APPDATA%\gcode`,项目级用 `/.gcode` | + +## 一期做 + +- LangGraph 两节点 ReAct:`llm_call` ⇄ `tool_node` +- 工具:读文件、列目录、精确替换、执行命令(含 Windows 编码与超时杀进程) +- HITL、SQLite 会话、短静态 prompt + 动态上下文 +- 终端探测 + TUI + REPL 回退 + `/edit` +- 模型注册表与启动选模型 +- 薄 MCP 客户端,默认只接 Playwright MCP + +## 一期不做 + +- Skills、Subagent、Plan 模式(Qoze README 有、代码无,G-CODE 不宣传未实现功能) +- Island 菜单栏、语音 / PyAudio、飞书 +- 进程内 Playwright、`chrome-devtools-mcp`(附着已开 Chrome,不是 Playwright) +- Vertex / Anthropic / Bedrock 原生协议 +- 全局单例 `llm` / `agent`;工具反向 import 图模块 + +## 验收原则 + +1. 以代码为准,文档不写没做的功能 +2. 核心图不依赖 TUI:REPL 与 TUI 都只消费 `astream` +3. 能 15 分钟讲完:输入从哪进、图怎么转、工具如何确认、会话存在哪 +4. 密钥只放用户目录或环境变量,不进 git diff --git a/项目资料/开发方案/01-Windows中文输入方案.md b/项目资料/开发方案/01-Windows中文输入方案.md new file mode 100644 index 0000000..55a5f95 --- /dev/null +++ b/项目资料/开发方案/01-Windows中文输入方案.md @@ -0,0 +1,79 @@ +# 01 Windows 中文输入方案 + +## 结论(已拍板:方案 C) + +主界面是 TUI。启动时检测终端: + +- Windows Terminal / VS Code / Cursor 等可用宿主 → 进入自研 TUI +- 经典 cmd(conhost)→ **不进入 Textual**,打印如何用 WT 启动,并 **自动回退 REPL** + +不把「在 cmd 里用 Textual 打中文」列为验收。TUI 在 WT 里若中文仍偶发失败,用 `/edit` 外开编辑器写长提示。 + +## 根因 + +你在 Qoze 的 cmd 里打不了中文,不是没设 UTF-8。 + +Textual 在 Windows 上走 `WindowsDriver`:`ReadConsoleInputW` + Raw VT + Kitty Keyboard Protocol(`\x1b[>1u`)。应用一旦 raw 读键,控制台宿主无法完成 IME 组字(拼音 → 汉字)。`PYTHONUTF8` 只影响 Python 输出编码,不修复组字。 + +Qoze 里 [utils/iterm_driver.py](D:\ideaProject\QozeCode-main\utils\iterm_driver.py) 用拦截 Kitty 协议修 iTerm 中文,但从未接到主程序;Windows 侧没有对应处理。安装脚本生成 `qoze.cmd`,更容易落到 conhost。 + +当前机器是 Windows 10 1903(build 18362)时,conhost / 早期 ConPTY 的 IME 更差。只在 README 写「请用 Windows Terminal」不够,必须启动时探测并降级。 + +```mermaid +flowchart TD + keyPress[按键] --> host{谁拥有输入} + host -->|熟模式 cooked REPL| ime[系统 IME 组字] + ime --> appStdin[应用读到完整汉字] + host -->|生模式 raw TUI| skipIme[应用直接吃按键] + skipIme --> fail[拼音散开或无法上屏] +``` + +## 业内对照(为何选 C) + +| 方案 | 含义 | G-CODE | +|------|------|--------| +| A. 永远 REPL | 输入权归终端,cmd 也能打中文 | 仅作 conhost 回退,不是主界面 | +| B. `/edit` 外开编辑器 | 类似 git commit | TUI 下的第二兜底 | +| C. 检测终端再决定 | Claude Code / Gemini CLI 一类「请用 WT」的工程化 | **已选**:能开 TUI 就开,不能开就回退 | +| D. 自研 Windows Driver 关 Kitty | Qoze 的 iTerm 思路 | TUI 路径建议做,cmd 仍救不了 | +| E/F. Web 窗 / Win32 悬浮输入框 | 浏览器 IME 完美,或极重 | 不做 | + +没有银弹。Cursor、Claude Code、Textual 系 TUI 在 Windows 中文输入上至今都有缺口。稳妥做法是:**不在不支持的宿主里硬开 raw TUI**。 + +## REPL 是什么 + +Read-Eval-Print Loop:读一行 → 执行 → 打印 → 再等下一行。不进全屏交替缓冲区,IME 归终端。G-CODE 里它不是产品脸,只是逃生口: + +```text +gcode> 请总结 README +[think] ... +[tool] read_file README.md +[final] ... +``` + +主界面仍是 TUI:顶栏、消息气泡、思考区、工具状态、底部输入框。自己写 widget 与样式。 + +## 探测规则 + +允许 TUI(满足任一即可,实现时以代码为准并记日志): + +- 环境变量 `WT_SESSION` 存在 +- `TERM_PROGRAM` 为 `vscode` 等已知 PTY +- 父进程名为 `WindowsTerminal.exe` + +否则视为 conhost: + +1. 打印:当前是传统 CMD,中文输入在全屏界面不可用;请用 Windows Terminal 打开本目录后执行 `gcode` +2. 进入 REPL,功能可用(HITL 用 y/N 文本确认) +3. `gcode --ui tui` 在 conhost 上应失败并提示,禁止硬开 +4. `gcode --ui repl` 任意终端都可强制熟模式 + +TUI 启动后仍建议拦截 / 关闭 Kitty 协议(`\x1b[>1u`),降低 WT 下 IME 失败概率。 + +## 验收 + +- 在 Windows Terminal 中:`gcode` 进入 TUI,能完成一轮「提问 → 工具 → 回复」 +- 在经典 cmd 中:不进入 TUI;有引导文案;REPL 能打中文并完成同一轮任务 +- `--ui tui` 在 cmd 中非零退出或明确拒绝,不黑屏假死 +- `/edit` 能打开 `%EDITOR%` 或记事本,保存后把内容当作用户输入 +- 不验收:cmd 内 Textual 输入框打中文 diff --git a/项目资料/开发方案/02-架构与目录.md b/项目资料/开发方案/02-架构与目录.md new file mode 100644 index 0000000..bc5cff2 --- /dev/null +++ b/项目资料/开发方案/02-架构与目录.md @@ -0,0 +1,154 @@ +# 02 架构与目录 + +源码落地:`D:\ideaProject\gcode`(本次只定结构,不建库)。 + +## 分层 + +```text +┌─────────────────────────────────────────────┐ +│ ui/detect + tui / repl │ +│ 探测终端、画界面、收集确认(y/N、diff) │ +├─────────────────────────────────────────────┤ +│ graph + session + prompt │ +│ ReAct、checkpoint、静态/动态提示词 │ +├─────────────────────────────────────────────┤ +│ models(注册表 + openai_compatible) │ +├─────────────────────────────────────────────┤ +│ tools + safety + mcp │ +│ 文件/命令沙箱、HITL、Playwright MCP │ +└─────────────────────────────────────────────┘ +``` + +依赖只能从上到下。工具不得 `import` 图模块去改全局 `llm` / `agent`。TUI 与 REPL 都只消费 `graph.astream(...)`。 + +```mermaid +flowchart LR + detect[终端探测] -->|WT 等| tui[自研TUI] + detect -->|conhost| repl[REPL回退] + tui --> graph[LangGraph ReAct] + repl --> graph + graph --> llmCall[llm_call] + llmCall -->|tool_calls| toolNode[tool_node] + toolNode --> safety[HITL] + safety --> llmCall + llmCall -->|纯文本| uiOut[TUI或REPL渲染] +``` + +## 目录骨架 + +```text +D:\ideaProject\gcode\ + pyproject.toml + README.md + src/gcode/ + __init__.py + cli.py # 入口:解析参数、探测、选 UI + config.py # 单一 home:%APPDATA%\gcode 与 /.gcode + windows.py # 代码页、默认 PowerShell、Job Object / taskkill + prompt.py + graph.py # StateGraph:llm_call / tool_node + session.py # sqlite checkpointer + thread_id + safety.py # 写文件 diff、shell 确认;回调由 UI 注入 + ui/ + detect.py + repl.py + tui/ # 自研 Textual,不拷 Qoze CSS/logo + models/ + registry.py # 读 models.yaml + openai_compatible.py + tools/ + fs.py + shell.py + mcp/ + client.py # 薄客户端,默认只起 @playwright/mcp + tests/ + test_path_sandbox.py + test_graph_smoke.py + test_detect_terminal.py +``` + +项目级数据:`/.gcode/`(checkpoints、rules、debug log)。 +用户级:`%APPDATA%\gcode\`(`models.yaml`、mcp 配置、token 统计)。禁止再混用 `~\.gcode` 与 `%APPDATA%` 两套根。 + +## Prompt 怎么写 + +不要一上来写 400 行。对照 Qoze 的 [system_prompt.py](D:\ideaProject\QozeCode-main\utils\system_prompt.py) 学 **静态/动态拆分**,不要整段粘贴。G-CODE 工具清单不同,抄过来模型会去调不存在的 `dispatch_subagent`。 + +每次加一段之前问: + +1. 模型此刻有哪些工具?没实现的一句不写 +2. 最容易做错的 3 件事?只为这些写硬规则 +3. 静态还是动态?每次一样 → System;会变(cwd、git、rules)→ 动态段 +4. 能用代码拦住的不要只靠文字(路径沙箱在 `read_file` 里拒绝) +5. 用失败任务加规则,不用想象加规则 + +静态段先控制在 80~150 行:身份、工具怎么选、5~8 条硬规则、ReAct 与「连续 3 次无进展就问人」、中文推理、TUI 内避免 emoji。 + +动态段保持短:cwd、目录树摘要、git 摘要、`.gcode/rules`。 + +## 多模型 + +不复制 Qoze 的枚举树。`models.yaml` 示例: + +```yaml +providers: + deepseek: + api: openai_compatible + base_url: https://api.deepseek.com/v1 + api_key_env: DEEPSEEK_API_KEY + models: + - id: deepseek-v4-flash + vision: false + reasoning: false + - id: deepseek-v4-pro + vision: false + reasoning: true + agicto: + api: openai_compatible + base_url: https://api.agicto.cn/v1 + api_key_env: GCODE_API_KEY + models: + - id: deepseek-v4-flash + vision: false + reasoning: false +``` + +一期只实现 `openai_compatible`。加一家模型改 yaml,不改 Python。`reasoning_content` 尽量显式解析,避免猴子补丁 LangChain。Vertex / Anthropic / Bedrock 有账号再加 adapter。 + +启动:`gcode --model deepseek-v4-flash` 或 TUI 内选择。 + +## 人在回路 + +`safety.py` 定义确认点,UI 注入回调(TUI 用对话框,REPL 用 y/N): + +| 动作 | 展示 | 拒绝时 | +|------|------|--------| +| 写文件 / 替换 | unified diff | ToolMessage:「用户拒绝本次写入」 | +| `execute_command` | 完整命令 + 工作目录 | ToolMessage:「用户拒绝执行该命令」 | + +读文件、列目录、只读 git 不必确认。安装依赖、构建、测试、`git commit` / `push` 必须走确认(prompt 硬规则 + HITL 双保险)。 + +## 工具 + +一期内置: + +- `read_file` / `list_dir` / `replace_in_file`:仅 cwd 或用户级 gcode 目录;拒绝 `..` +- `execute_command`:默认 PowerShell;输出 UTF-8 失败则 GBK;超时用 Job Object 或 `taskkill /T`,不要只 `terminate()` 留孤儿 + +浏览器不进 `tools/` 进程内实现,见下一节。 + +## Playwright MCP(仅途径 A) + +官方:`npx -y @playwright/mcp@latest`。 + +G-CODE 薄 MCP 客户端:stdio 拉起 → 把 tools 动态交给 `bind_tools`。MCP 只当 Playwright 插座,一期不接 GitHub / Postgres。不复活 Qoze 已注释的 `browser_tool.py`,不默认 `chrome-devtools-mcp`(那是 `--remote-debugging-port=9222` 附着已开 Chrome)。 + +硬依赖 Node.js / npx。检测不到则 TUI/REPL 提示「浏览器工具不可用,请安装 Node.js」,没有 Python Playwright 备选。 + +## 相对 Qoze 必须避开的结构 + +- 模块级全局 `llm`、`agent`、`tools_by_name` 被 MCP/TUI 到处改 +- `mcp_tools` / `subagent_tool` 反向 import `qoze_code_agent` +- `qoze_tui.py` 里命令路由、拼消息、token、流式全混在一个 App 类 +- 配置路径分裂:`%APPDATA%\qoze` vs `~\.qoze` +- shell 一律按 UTF-8 解码(cmd 的 `dir` 常为 GBK) diff --git a/项目资料/开发方案/03-分期实施与优化清单.md b/项目资料/开发方案/03-分期实施与优化清单.md new file mode 100644 index 0000000..3005f1f --- /dev/null +++ b/项目资料/开发方案/03-分期实施与优化清单.md @@ -0,0 +1,122 @@ +# 03 分期实施与优化清单 + +源码:`D:\ideaProject\gcode`(阶段 1 再 `git init`)。 +原则:每一阶段有过关标准;没过不要开下一阶段。不要边读 Qoze 边整文件复制。 + +--- + +## 阶段 0|对照方案精读(0.5~1 天) + +精读 Qoze 时带着本方案,不写 G-CODE 业务代码。 + +过关:能口述 ReAct 节点顺序、方案 C 在 cmd 下会怎样、HITL 拒绝后图为什么不会崩。 + +--- + +## 阶段 1|心脏(图 + 四件套 + HITL) + +只做 REPL,先保证能打中文、能改文件。 + +- `graph.py`:`START → llm_call ⇄ tool_node → END` +- `tools/fs.py` + `tools/shell.py` + `safety.py`(回调先做成终端 y/N) +- `session.py`:sqlite + `thread_id` +- `prompt.py`:短静态 + cwd/树 +- `models/`:先能跑通一家 OpenAI 兼容(例如 Agicto / DeepSeek) +- `windows.py`:UTF-8→GBK 解码;超时杀进程树 + +过关: + +- 在任意项目目录启动,完成「读 README → 改一处 → 跑命令看结果」 +- 路径逃出 cwd 被拒绝(有测试) +- mock LLM 的图测试:一次 tool_call 再纯文本,能 END +- 写入和 shell 输入 `n` 会取消,模型能收到拒绝原因 +- 重启进程同一 session 能接着聊 + +--- + +## 阶段 2|主界面(TUI + 方案 C) + +- `ui/detect.py` + `ui/tui/` + `ui/repl.py` +- TUI:消息列表、思考流、工具卡片、输入框(自己的样式) +- conhost:拒绝 TUI、引导 WT、回退 REPL +- `--ui tui` / `--ui repl`;Kitty 协议在 TUI 路径关闭 +- `/edit`、`/clear`、`/help`、`/quit` + +过关:见 [01-Windows中文输入方案.md](./01-Windows中文输入方案.md) 验收表。 +核心图仍可在无 Textual 时单测。 + +--- + +## 阶段 3|多模型注册表 + +把阶段 1 的「写死一家」换成 `models.yaml`。TUI 可选模型。加一家 = 改配置。 +`reasoning` 模型要把思考内容接到思考区,尽量不打 LangChain 猴子补丁。 + +过关:至少两家兼容端点(例如 DeepSeek 官方 + Agicto)能切换完成同一任务。 + +--- + +## 阶段 4|Playwright MCP + +薄 `mcp/client.py`,默认只配置 `@playwright/mcp`。无 Node 时提示不可用。 +prompt 静态段此时才写「如何使用浏览器工具」,之前禁止出现。 + +过关:WT 下让 Agent 打开一个本地或公开页、取快照或截图、把结果写进对话。公司内网站点注意别把 Cookie 打进日志。 + +--- + +## 阶段 5|按需增强(每次只加一个) + +从下面挑,不要一次全上: + +- `.gcode/rules/` 项目规则 +- git 状态注入动态上下文 +- `/compact` 对话摘要截断 +- Java / Maven 专用工具(`pom` 模块、失败日志截断) +- 公司 GitLab / Jenkins **只读** 查询(写操作必须再走 HITL) + +仍默认不做:Skills、Subagent、Plan 模式、语音、Island、其它 MCP 市场。 + +--- + +## 相对 Qoze 的优化清单 + +### 一期就该比它好 + +| 问题(Qoze) | G-CODE | +|--------------|--------| +| cmd 中文 IME 失败,只推荐 WT | 方案 C,启动探测 + 回退 | +| `execute_command` 强制 UTF-8 | UTF-8 失败再 GBK;默认 PowerShell | +| 无 HITL,`ask_for_user` 已注释 | 写文件 diff + 命令确认 | +| `%APPDATA%\qoze` 与 `~\.qoze` 分裂 | 单一 `get_home_dir()` | +| Windows 超时只 `terminate()` | Job Object 或 `taskkill /T` | +| README 宣传 `/plan`、内置 Playwright,代码没有 | 文档以代码为准 | +| TUI 绑死核心、全局单例、工具反向 import | 分层;UI 只消费 stream | + +### 刻意不搬 + +Island、语音、飞书 dead code、1300 行注释浏览器、十家厂商 if-else、Subagent、Skills。 + +### 二期再看 + +上下文压缩、规则文件、git 注入、Java/CI 工具箱。 + +--- + +## 明确不要做的事 + +- 不要把 Qoze 的 CSS、ASCII logo、长 prompt 整段拷进 G-CODE +- 不要在没沙箱的情况下开放 `execute_command` +- 不要把 API Key 写进仓库 +- 不要在 cmd 里硬开 TUI 充「功能完整」 +- 不要宣传未实现的 Plan / 语音 / Subagent + +## 阶段 1 完成定义 + +当下面全是「是」,才进入阶段 2: + +- [ ] `D:\ideaProject\gcode` 能独立 `pip install -e .` 跑起来 +- [ ] 四件套工具 + HITL + 会话恢复 +- [ ] 路径沙箱与图的 mock 测试存在 +- [ ] README 是自己写的架构说明 +- [ ] 不看代码能画出数据流 diff --git a/项目资料/开发方案/README.md b/项目资料/开发方案/README.md new file mode 100644 index 0000000..2341470 --- /dev/null +++ b/项目资料/开发方案/README.md @@ -0,0 +1,34 @@ +# G-CODE 开发方案 + +本目录是 **G-CODE 要做成什么样、先做什么**。 +上级目录(`../01-架构与调用链.md` 起)是 **怎么读懂 QozeCode**。先精读,再按这里开工。 + +| 路径 | 用途 | +|------|------| +| 参考实现 | `D:\ideaProject\QozeCode-main`(只借鉴架构,不换皮复制) | +| G-CODE 源码 | `D:\ideaProject\gcode`(尚未建库;按 [03-分期实施与优化清单.md](./03-分期实施与优化清单.md) 再开) | +| 本方案 | `D:\myProgram\Learning\ai-coding改写\开发方案` | + +许可证:QozeCode 为 Apache 2.0。G-CODE 自己重写同构实现,保留「能讲清楚」优先于「功能一样全」。 + +## 怎么读 + +| 顺序 | 文件 | 用途 | +|------|------|------| +| 1 | [00-产品定义.md](./00-产品定义.md) | 一句话、做/不做、已拍板决策 | +| 2 | [01-Windows中文输入方案.md](./01-Windows中文输入方案.md) | 方案 C:检测终端、TUI / REPL | +| 3 | [02-架构与目录.md](./02-架构与目录.md) | 分层、prompt、多模型、HITL、Playwright MCP | +| 4 | [03-分期实施与优化清单.md](./03-分期实施与优化清单.md) | 阶段、验收、相对 Qoze 的优化 | + +## 一句话 + +> G-CODE:跑在 Windows 上的个人 Coding Agent。主界面是自研 TUI;经典 CMD 里自动回退 REPL 以保证能打中文。默认 OpenAI 兼容协议;改文件和跑命令前要确认;浏览器只走 Playwright MCP。 + +## 已拍板(摘要) + +- 主界面:自研 Textual TUI(观感可接近 Qoze,实现自己写) +- Windows:方案 C(conhost 拒绝 TUI → 提示 Windows Terminal → 回退 REPL) +- 多模型:yaml 注册表 + `openai_compatible` +- 浏览器:只接 `@playwright/mcp`,无 Node 则提示不可用,无 Python 备选 +- 安全:写文件出 diff 确认;shell 出命令确认 +- 一期不做:Skills、Subagent、Plan 模式、Island、语音、飞书、Vertex/Anthropic 原生协议 diff --git a/项目资料/笔记/观察日志.md b/项目资料/笔记/观察日志.md new file mode 100644 index 0000000..07815cc --- /dev/null +++ b/项目资料/笔记/观察日志.md @@ -0,0 +1,74 @@ +# 观察日志(自己填) + +## 怎么做(阶段 0) + +一次只丢 **一个** 任务,等它完全停下来(底部不再转圈、工具面板不再“执行中”)再记笔记,再发下一条。不要五条一起贴。 + +### 1. 启动 + +在 **Windows Terminal** 或弹出的 cmd 窗口里(不要用会乱码的旧 CMD 更好): + +```powershell +cd D:\ideaProject\QozeCode-main +$env:PYTHONUTF8 = "1" +.\.venv\Scripts\python.exe qoze_tui.py --model deepseek-v4-flash +``` + +进界面后,输入框在底部。 + +### 2. 界面上看什么 + +| 你看到的 | 对应图里的节点 | +| ----------------- | --------------------------- | +| 思考区在滚动(thinking) | `llm_call`:模型在想、可能准备调工具 | +| 工具状态:某个名字闪「执行中」 | `tool_node`:真的在跑工具 | +| 工具变成完成/失败,思考区又开始动 | 回到 `llm_call`:看了观察结果,再决定 | +| 出现一整段助手回复,然后安静 | 没有 `tool_calls` 了,图走到 `END` | + +**轮次** = 思考了几次(也就是 `llm_call` 跑了几次)。 +例如:想一次 → 调工具 → 再想一次 → 回答,就是 **2 轮**。 + +**工具序列** 按出现顺序写名字,例如:`execute_command → read_file → execute_command`。 + +当前这版 Qoze **没有**挂上 `list_files` / `grep_file` / `write_file` / `replace_in_file`。所以: + +- 列 py 文件、grep、改文件,多半会走 `execute_command`(dir / findstr / powershell) +- 读 README 更可能走 `read_file` +- 这不是失败,这就是你要观察的点 + +### 3. 五条任务(逐条复制,发完等停) + +在 Qoze 输入框里,一次贴一条: + +1. `当前目录有哪些 py 文件` +2. `读 README 前 80 行并总结` +3. `用 grep 找 init_agent 在哪` +4. `给 tools/date_tools.py 里的 get_current_datetime 函数上方加一行中文注释:# 观察任务:由 Agent 添加` +5. `执行 python --version` + +第 4 条故意指定了文件和函数,方便你对照磁盘看它到底怎么改的。跑完用编辑器打开 `tools/date_tools.py` 看有没有那行注释。 + +不需要的话,第 4 条跑完可以自己把注释删掉。 + +### 4. 每条停下来立刻记下面模板 + +看不准轮次就写「至少 N」:思考区亮了几次就算几次。走歪就写:找错目录、用了 cat 而不是 read_file、改了别的文件、命令在 Windows 上写了 `ls` 等。 + +过关自测(不看文档口述): + +> 用户回车 → llm_call → 有工具就 tool_node → 再 llm_call → 直到不再调工具 → END + +## 模板 + +```text +日期: +任务: +实际工具序列: +轮次: +走歪的地方: +我学到的一点: +``` + +## 记录 + +(从阶段 0 开始往下追加)