diff --git a/README.md b/README.md index 5d860a78..938b72b8 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,23 @@ python frontends/qtapp.py # Qt-based desktop app streamlit run frontends/stapp2.py # Alternative Streamlit UI ``` +### Codeg + + + + +
+ +[Codeg](https://github.com/yiqi-017/codeg) (`feat/genericagent-integration` branch) is a desktop/web UI that connects GenericAgent alongside other agents (Claude Code, Gemini, Codex, etc.) in a unified interface with a polished, modern UI. + +> This integration is usable now. Some features are still being refined — feedback welcome. + +Place your GenericAgent directory alongside the codeg project. Codeg will auto-detect `frontends/genericagent_acp_bridge.py` and launch GenericAgent as a local ACP agent. + + +Codeg Demo +
+ ### Common Chat Commands The default Streamlit desktop UI started by `python launch.pyw`, plus the QQ / Telegram / Feishu / WeCom / DingTalk frontends, support these chat commands: @@ -419,6 +436,23 @@ python frontends/qtapp.py # 基于 Qt 的桌面应用 streamlit run frontends/stapp2.py # 另一种 Streamlit 风格 UI ``` +### Codeg前端 + + + + +
+ +[Codeg](https://github.com/yiqi-017/codeg)(`feat/genericagent-integration` 分支)是一个桌面/Web UI,可以将 GenericAgent 与其他代理(Claude Code、Gemini、Codex 等)在统一界面中并行使用,UI 更加精美。 + +> 此集成已可使用,部分功能仍在完善中,欢迎体验反馈。 + +将 GenericAgent 目录放在 codeg 项目同级目录下,Codeg 会自动检测 `frontends/genericagent_acp_bridge.py` 并将 GenericAgent 作为本地 ACP 代理启动。 + + +Codeg Demo +
+ ### 通用聊天命令 默认通过 `python launch.pyw` 启动的 Streamlit 桌面 UI,以及 QQ / Telegram / 飞书 / 企业微信 / 钉钉前端,都支持以下命令: diff --git a/assets/demo/codeg-demo.gif b/assets/demo/codeg-demo.gif new file mode 100644 index 00000000..cd120eff Binary files /dev/null and b/assets/demo/codeg-demo.gif differ diff --git a/frontends/genericagent_acp_bridge.py b/frontends/genericagent_acp_bridge.py new file mode 100644 index 00000000..c657950c --- /dev/null +++ b/frontends/genericagent_acp_bridge.py @@ -0,0 +1,355 @@ +import io +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Must run BEFORE importing agentmain — it reconfigures stdout at import time, +# and its submodules may print() during init. We capture the raw binary stdout +# for ACP JSON-RPC, then redirect the text-mode stdout to stderr so any stray +# prints from agentmain/llmcore don't pollute the ACP channel. +if sys.platform == "win32": + import msvcrt + _stdout_fd = os.dup(sys.__stdout__.fileno()) + msvcrt.setmode(_stdout_fd, os.O_BINARY) + _acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0) + msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY) + # Mark the ACP fd as non-inheritable so child processes can't write to it. + os.set_inheritable(_stdout_fd, False) + # Redirect the original stdout fd to stderr so child processes + # (tool calls) don't write into the ACP JSON-RPC channel. + os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno()) +else: + _stdout_fd = os.dup(sys.__stdout__.fileno()) + os.set_inheritable(_stdout_fd, False) + _acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0) + os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno()) + + +class _StdoutToStderrRouter(io.TextIOBase): + """Redirect text-mode stdout to stderr so agentmain prints don't leak.""" + def writable(self): return True + def write(self, s): + if s: + sys.stderr.write(s) + sys.stderr.flush() + return len(s) if s else 0 + def flush(self): sys.stderr.flush() + +sys.stdout = _StdoutToStderrRouter() + +import argparse +import queue +import threading +import traceback +import uuid +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from agentmain import GeneraticAgent + + +JSONRPC_VERSION = "2.0" +ACP_PROTOCOL_VERSION = 1 + + +def eprint(*args: Any) -> None: + print(*args, file=sys.stderr, flush=True) + + +def make_text_block(text: str) -> Dict[str, Any]: + return {"type": "text", "text": text} + + +def make_session_update(session_id: str, update: Dict[str, Any]) -> Dict[str, Any]: + return { + "jsonrpc": JSONRPC_VERSION, + "method": "session/update", + "params": {"sessionId": session_id, "update": update}, + } + + +def compact_json(obj: Dict[str, Any]) -> str: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + +def parse_jsonrpc_line(line: str) -> Optional[Dict[str, Any]]: + stripped = line.strip() + if not stripped: + return None + try: + obj = json.loads(stripped) + except json.JSONDecodeError: + return None + return obj if isinstance(obj, dict) else None + + +def content_blocks_to_text(blocks: List[Dict[str, Any]]) -> str: + parts: List[str] = [] + for block in blocks: + if not isinstance(block, dict): + continue + block_type = block.get("type") + if block_type == "text": + text = block.get("text") + if isinstance(text, str) and text: + parts.append(text) + elif block_type == "resource_link": + name = block.get("name") or "resource" + uri = block.get("uri") or "" + desc = block.get("description") or "" + parts.append(f"[ResourceLink] {name}: {uri}\n{desc}".strip()) + elif block_type == "resource": + uri = block.get("uri") or "resource" + text = block.get("text") + if isinstance(text, str) and text: + parts.append(f"[Resource] {uri}\n{text}") + else: + parts.append(f"[Resource] {uri}") + elif block_type == "image": + uri = block.get("uri") or "inline-image" + parts.append(f"[Image omitted] {uri}") + else: + parts.append(f"[Unsupported content block: {block_type}]") + return "\n\n".join(p for p in parts if p).strip() + + +def jsonrpc_error(code: int, message: str, req_id: Any = None, data: Any = None) -> Dict[str, Any]: + err: Dict[str, Any] = {"code": code, "message": message} + if data is not None: + err["data"] = data + return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "error": err} + + +def jsonrpc_result(req_id: Any, result: Any) -> Dict[str, Any]: + return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "result": result} + + +@dataclass +class SessionState: + session_id: str + cwd: str + agent: GeneraticAgent + current_prompt_id: Any = None + prompt_lock: threading.Lock = field(default_factory=threading.Lock) + + +class GenericAgentAcpBridge: + def __init__(self, llm_no: int = 0): + self.llm_no = llm_no + self._json_out = _acp_stdout + self._write_lock = threading.Lock() + self._sessions: Dict[str, SessionState] = {} + self._shutdown = False + + def write_message(self, msg: Dict[str, Any]) -> None: + payload = compact_json(msg) + raw = (payload + "\n").encode("utf-8") + method = msg.get("method", msg.get("id", "?")) + eprint(f"[ACP-BRIDGE] >>> {payload[:500]}") + try: + with self._write_lock: + self._json_out.write(raw) + self._json_out.flush() + except Exception as e: + eprint(f"[ACP-BRIDGE] WRITE FAILED: {type(e).__name__}: {e}") + + def new_agent(self) -> GeneraticAgent: + agent = GeneraticAgent() + agent.next_llm(self.llm_no) + agent.verbose = True + threading.Thread(target=agent.run, daemon=True).start() + return agent + + def handle_initialize(self, req_id: Any, params: Dict[str, Any]) -> None: + requested_version = params.get("protocolVersion", ACP_PROTOCOL_VERSION) + version = ACP_PROTOCOL_VERSION if requested_version == ACP_PROTOCOL_VERSION else ACP_PROTOCOL_VERSION + result = { + "protocolVersion": version, + "agentCapabilities": { + "loadSession": False, + "mcpCapabilities": {"http": False, "sse": False}, + "promptCapabilities": { + "image": False, + "audio": False, + "embeddedContext": False, + }, + "sessionCapabilities": {}, + }, + "agentInfo": { + "name": "genericagent-acp", + "title": "GenericAgent", + "version": "0.1.0", + }, + "authMethods": [], + } + self.write_message(jsonrpc_result(req_id, result)) + + def handle_session_new(self, req_id: Any, params: Dict[str, Any]) -> None: + cwd = params.get("cwd") + if not isinstance(cwd, str) or not cwd: + self.write_message(jsonrpc_error(-32602, "cwd is required", req_id)) + return + if not os.path.isabs(cwd): + cwd = os.path.abspath(cwd) + session_id = f"ga_{uuid.uuid4().hex}" + agent = self.new_agent() + session = SessionState(session_id=session_id, cwd=cwd, agent=agent) + self._sessions[session_id] = session + self.write_message( + jsonrpc_result( + req_id, + { + "sessionId": session_id, + "modes": None, + "configOptions": None, + }, + ) + ) + + def handle_session_prompt(self, req_id: Any, params: Dict[str, Any]) -> None: + session_id = params.get("sessionId") + prompt_blocks = params.get("prompt") + session = self._sessions.get(session_id) + if session is None: + self.write_message(jsonrpc_error(-32602, "unknown sessionId", req_id)) + return + if not isinstance(prompt_blocks, list): + self.write_message(jsonrpc_error(-32602, "prompt must be an array", req_id)) + return + prompt_text = content_blocks_to_text(prompt_blocks) + if not prompt_text: + self.write_message(jsonrpc_error(-32602, "prompt must contain text or supported content", req_id)) + return + + with session.prompt_lock: + if session.current_prompt_id is not None: + self.write_message( + jsonrpc_error(-32603, "session already has an active prompt", req_id) + ) + return + session.current_prompt_id = req_id + + def run_prompt() -> None: + stop_reason = "end_turn" + try: + dq = session.agent.put_task(prompt_text, source="acp") + self._drain_agent_queue(session, dq) + except Exception as exc: + stop_reason = "end_turn" + self.write_message( + make_session_update( + session.session_id, + { + "sessionUpdate": "agent_message_chunk", + "content": make_text_block( + f"[Bridge error] {type(exc).__name__}: {exc}" + ), + }, + ) + ) + eprint("[GenericAgent ACP] prompt thread failed:", traceback.format_exc()) + finally: + with session.prompt_lock: + finished_req_id = session.current_prompt_id + session.current_prompt_id = None + if finished_req_id is not None: + import time + time.sleep(0.1) + self.write_message( + jsonrpc_result(finished_req_id, {"stopReason": stop_reason}) + ) + + threading.Thread(target=run_prompt, daemon=True).start() + + def _drain_agent_queue(self, session: SessionState, dq: "queue.Queue[Dict[str, Any]]") -> None: + last_sent = "" + while True: + item = dq.get() + if not isinstance(item, dict): + continue + text = item.get("done") or item.get("next") or "" + if isinstance(text, str) and len(text) > len(last_sent): + delta = text[len(last_sent):] + last_sent = text + try: + self.write_message( + make_session_update( + session.session_id, + { + "sessionUpdate": "agent_message_chunk", + "content": make_text_block(delta), + }, + ) + ) + except Exception as e: + eprint(f"[ACP-BRIDGE] ERROR writing update: {e}") + if "done" in item: + break + + def handle_session_cancel(self, params: Dict[str, Any]) -> None: + session_id = params.get("sessionId") + session = self._sessions.get(session_id) + if session is None: + return + if session.current_prompt_id is not None: + session.agent.abort() + + def handle_message(self, msg: Dict[str, Any]) -> None: + method = msg.get("method") + req_id = msg.get("id") + params = msg.get("params") or {} + + try: + if method == "initialize": + self.handle_initialize(req_id, params) + elif method == "session/new": + self.handle_session_new(req_id, params) + elif method == "session/prompt": + self.handle_session_prompt(req_id, params) + elif method == "session/cancel": + self.handle_session_cancel(params) + elif method == "session/load": + self.write_message(jsonrpc_error(-32601, "session/load not supported", req_id)) + elif method == "session/list": + self.write_message(jsonrpc_error(-32601, "session/list not supported", req_id)) + elif method == "session/close": + self.write_message(jsonrpc_result(req_id, {})) + elif method is None: + if req_id is not None: + self.write_message(jsonrpc_error(-32600, "invalid request", req_id)) + else: + if req_id is not None: + self.write_message(jsonrpc_error(-32601, f"method not found: {method}", req_id)) + except Exception as exc: + eprint("[GenericAgent ACP] request handler failed:", traceback.format_exc()) + if req_id is not None: + self.write_message( + jsonrpc_error(-32603, f"internal error: {type(exc).__name__}: {exc}", req_id) + ) + + def serve(self) -> None: + eprint("[GenericAgent ACP] bridge started") + stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace") if hasattr(sys.stdin, 'buffer') else sys.stdin + for raw_line in stdin: + msg = parse_jsonrpc_line(raw_line) + if msg is None: + continue + self.handle_message(msg) + if self._shutdown: + break + eprint("[GenericAgent ACP] bridge stopped") + + +def main() -> int: + parser = argparse.ArgumentParser(description="GenericAgent ACP bridge over stdio") + parser.add_argument("--llm-no", type=int, default=0, help="LLM index for GenericAgent") + args = parser.parse_args() + bridge = GenericAgentAcpBridge(llm_no=args.llm_no) + bridge.serve() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())