diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..b3ebfcb8b
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+# Keep platform-specific scripts with safe line endings.
+*.sh text eol=lf
+*.command text eol=lf
+*.ps1 text eol=crlf
+*.bat text eol=crlf
diff --git a/.gitignore b/.gitignore
index c8ca6bf0d..5d9605dae 100644
Binary files a/.gitignore and b/.gitignore differ
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..fb9f4116e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,44 @@
+# Contributing to GenericAgent
+
+## Why This File Is Short
+
+GenericAgent's core is ~3K lines. Every file in this repo will be read by AI agents — potentially thousands of times. Extra words cost real tokens and push useful context out of the window, increasing hallucinations. This document practices what it preaches: **say only what matters.**
+
+## Before You Contribute
+
+1. **Read the codebase first.** It's small enough to read in one sitting. Understand the philosophy before proposing changes.
+2. **Open an Issue first** for anything non-trivial. Discuss before coding.
+
+## Code Standards
+
+All PRs go through a strict automated code review skill. Key expectations:
+
+- **Self-documenting code, minimal comments.** If code needs a paragraph to explain, rewrite it.
+- **Compact and visually uniform.** Fewer lines, consistent line lengths, no fluff.
+- **Small change radius.** Changing A shouldn't ripple through B, C, D.
+- **More features → less code.** Good abstractions make the codebase shrink, not grow.
+- **Let it crash by failure radius.** Critical errors fail loud; trivial ones pass silently. No blanket try-catch.
+
+> ⚠️ This review is deliberately strict — most AI-generated code (e.g. Claude Code output) will not pass as-is. Read the full principles before submitting.
+
+## Skill Contributions
+
+GenericAgent evolves through skills. Not all skills belong in the core repo:
+
+| Type | Where it goes | Example |
+|---|---|---|
+| **Fundamental / universal** | Core repo (`memory/`) | File search, clipboard, basic web ops |
+| **Domain-specific / niche** | Skill Marketplace *(coming soon)* | Stock screening, food delivery, specific API integrations |
+
+If your skill only makes sense for a specific workflow, it's a marketplace candidate, not a core PR.
+
+## PR Checklist
+
+- [ ] Issue linked or context explained in ≤3 sentences
+- [ ] Code passes the [review principles] self-check:
+ 1. Can I safely modify this locally without reading the whole codebase?
+ 2. Is there a clear core abstraction — new features add implementations, not modify old logic?
+ 3. Are change points converging at boundaries, not scattered everywhere?
+ 4. On failure, can I quickly locate the responsible module?
+- [ ] Net line count: ideally negative or zero for refactors
+- [ ] No unnecessary dependencies added
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..641d051c0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 lsdefine
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 8c7e2ff30..0697dea49 100644
--- a/README.md
+++ b/README.md
@@ -1,281 +1,818 @@
-# GenericAgent — 3,300 Lines to Full OS Autonomy
+
-[English](#english) | [中文](#chinese)
+
-
+# GenericAgent
-A minimalist autonomous agent framework that gives any LLM physical-level control over your PC — browser, terminal, file system, keyboard, mouse, screen vision, and mobile devices — in ~3,300 lines of Python.
+**A Minimal, Self-Evolving Autonomous Agent Framework**
-No Electron. No Docker. No Mac Mini. No 500K-line codebase. No paid installation service.
+*~3K lines of seed code · 9 atomic tools · ~100-line Agent Loop*
-## See It in Action
+
-
-
-"Order me a milk tea" — navigates a delivery app, picks items, and checks out.
-"Find GEM stocks with EXPMA golden cross, turnover > 5%" — quantitative screening via mootdx.
-
-
+
+
+
+
+
+
+
+
+
+
+
+**[English](#-english) · [中文](#-中文)**
+
+
+
+> 📌 **Official:** GitHub + https://gaagent.ai only. DintalClaw is the sole authorized commercial partner; others are not affiliated.
+
+---
+
+
+
+## 🌟 Overview
+
+**GenericAgent** is a minimal, self-evolving autonomous agent framework. Its core is just **~3K lines of code**. Through **9 atomic tools + a ~100-line Agent Loop**, it grants any LLM system-level control over a local computer — covering browser, terminal, filesystem, keyboard/mouse input, screen vision, and mobile devices (ADB).
+
+> Design philosophy — **don't preload skills, evolve them.**
+
+Every time GenericAgent solves a new task, it automatically crystallizes the execution path into a reusable **Skill**. The longer you use it, the more skills accumulate — forming a personal skill tree grown entirely from 3K lines of seed code.
+
+> 🤖 **Self-Bootstrap Proof** — Everything in this repository, from installing Git and running `git init` to every commit message, was completed autonomously by GenericAgent. The author never opened a terminal once.
+
+### 📑 Table of Contents
+
+- [Key Features](#-key-features)
+- [Demo Showcase](#-demo-showcase)
+- [Quick Start](#-quick-start)
+- [Usage](#-usage)
+- [Unlocking Advanced Capabilities](#-unlocking-advanced-capabilities)
+- [Architecture](#-architecture)
+- [Self-Evolution Mechanism](#-self-evolution-mechanism)
+- [Comparison](#-comparison)
+- [Evaluation](#-evaluation)
+- [Roadmap & News](#-roadmap--news)
+- [Community & Support](#-community--support)
+- [License](#-license)
+
+---
+
+## 📋 Key Features
+
+| Feature | Description |
+| :--- | :--- |
+| 🧬 **Self-Evolving** | Automatically crystallizes each task into a Skill. Capabilities grow with every use, forming your personal skill tree. |
+| 🪶 **Minimal Architecture** | ~3K lines of core code. Agent Loop is ~100 lines. No complex dependencies, zero deployment overhead. |
+| ⚡ **Strong Execution** | **TMWebdriver** injects into a real browser (preserving login sessions). 9 atomic tools take direct control of the system. |
+| 🔌 **High Compatibility** | Supports Claude / Gemini / Kimi / MiniMax and other major models. Cross-platform. |
+| 💰 **Token Efficient** | <30K context window — a fraction of the 200K–1M other agents consume. Less noise, fewer hallucinations, higher success rate, lower cost. |
+
+---
+
+## 🎯 Demo Showcase
-
-Autonomous web exploration — browses and summarizes on its own schedule.
-"Find expenses over ¥2K in the past 3 months" — drives Alipay on a phone via ADB.
-WeChat batch messaging — yes, it can drive WeChat too.
-
+
+ 🛡️ Real-Browser CAPTCHA Survival
+ 🌐 Autonomous Web Exploration
+
+
+
+
+
+
+ While configuring a Discord bot, an hCaptcha "Are you human?" challenge pops up mid-task — GA's real browser session passes it and the task continues. See Browser Realness .
+ Autonomously browses and periodically summarizes web content.
+
+
+ 🧋 Food Delivery Order
+ 📈 Quantitative Stock Screening
+
+
+
+
+
+
+ "Order me a milk tea" — navigates the delivery app, selects items, completes checkout.
+ "Find GEM stocks with EXPMA golden cross, turnover > 5%" — quantitative screening.
+
+
+ 💰 Expense Tracking
+ 💬 Batch Messaging
+
+
+
+
+
+
+ "Find expenses over ¥2K in the last 3 months" — drives Alipay via ADB.
+ Sends bulk WeChat messages, fully driving the WeChat client.
+
-## What Happens When You Use It
+---
+
+## 🚀 Quick Start
+
+> ⚠️ **Python version**: use **Python 3.11 or 3.12**. **Do not** use Python 3.14 — it is incompatible with `pywebview` and a few other GA dependencies.
+>
+> 📖 Detailed installation guide: **[installation.md](docs/installation.md)** · **[installation_zh.md(中文)](docs/installation_zh.md)**
+### For LLM Agents
+
+Fetch the installation guide and follow it:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/main/docs/installation.md
```
-You: "Read my WeChat messages"
-Agent: installs dependencies → reverse-engineers DB → writes reader script → saves as SOP
-Next time: instant recall, zero setup.
-You: "Monitor stock prices and alert me"
-Agent: installs mootdx → builds screening workflow → sets up scheduled task → saves as SOP
-Next time: one sentence to run.
+### For Humans
+
+#### Method 1 — Clone & install *(recommended)*
-You: "Send this file via Gmail"
-Agent: configures OAuth → writes send script → saves as SOP
-Next time: just works.
+```bash
+git clone https://github.com/lsdefine/GenericAgent.git && cd GenericAgent
+uv venv && uv pip install -e ".[ui]"
+cp mykey_template_en.py mykey.py # fill in your LLM API key
```
-**Dogfooding**: This repository — from installing Git to `git init`, writing this README, to every commit message — was built entirely by GenericAgent without the author opening a terminal once.
+Dependencies are deliberately tiered: the agent core needs only `requests`, plus four lightweight packages (`beautifulsoup4`, `bottle`, `simple-websocket-server`, `aiohttp`) for TMWebdriver's local server. The `[ui]` extra pulls in frontend libraries (Streamlit, `prompt_toolkit`/`rich` for the TUI, …) — install it for the bundled UIs, or skip it entirely and drive the agent headless. No Playwright, no LangChain, no browser binaries to download.
-Every task the agent solves becomes a permanent skill. After a few weeks, your instance has a unique skill tree — grown entirely from 3,300 lines of seed code.
+Then launch:
-## The Seed Philosophy
+```bash
+python frontends/tui_v3.py # Terminal UI (recommended)
+python launch.pyw # Streamlit web UI
+```
-Most agent frameworks ship as finished products. GenericAgent ships as a **seed**.
+#### Method 2 — One-line installer *(convenience)*
-The 5 core SOPs define how the agent thinks, remembers, and operates. From there, every new capability is discovered and recorded by the agent itself:
+Sets up a self-contained directory with an isolated Python environment, Git, and a ready-to-run package. The script is in [`assets/`](assets/) if you'd like to read it first.
-1. You ask it to do something new
-2. It figures out how (install dependencies, write scripts, test)
-3. It saves the procedure as a new SOP in its memory
-4. Next time, it recalls and executes directly
+**Windows PowerShell**
-The agent doesn't just execute — it **learns and remembers**.
+```powershell
+powershell -ExecutionPolicy Bypass -c "$env:GLOBAL=1; irm https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.ps1 | iex"
+```
-## Quick Start
+**Linux / macOS**
```bash
-# 1. Clone
-git clone https://github.com/lsdefine/pc-agent-loop.git
-cd pc-agent-loop
+GLOBAL=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/main/assets/ga_install.sh)"
+```
+
+> 💡 GenericAgent grows its environment **through the Agent itself** — don't pre-install everything. See [Unlocking Advanced Capabilities](#-unlocking-advanced-capabilities) below.
-# 2. Install minimal deps
-pip install streamlit pywebview
+---
-# 3. Configure API key
-cp mykey_template.py mykey.py
-# Edit mykey.py with your LLM API key
+## 💻 Usage
-# 4. Launch
-python launch.pyw
-```
+### Frontends
-**Also runs on Android** — tested successfully on Termux with `python agentmain.py` (CLI frontend):
+#### Terminal UI *(recommended)*
+
+A lightweight, scrollback-first terminal interface built on `prompt_toolkit` + `rich`. Supports multiple concurrent sessions and real-time streaming.
```bash
-# In Termux
-cd /sdcard/ga
-python agentmain.py
+python frontends/tui_v3.py
```
-Once running, tell the agent: *"Execute web setup SOP to unlock browser tools"* — it handles the rest. See [WELCOME_NEW_USER.md](WELCOME_NEW_USER.md) for the full bootstrap sequence.
+
+⚠️ Windows TUI Troubleshooting
+
+TUI rendering on Windows can be flaky depending on terminal + font. Common causes:
-## vs. Alternatives
+1. `prompt_toolkit` / `rich` are not on the latest version — `pip install -U prompt_toolkit rich` first.
+2. PowerShell / cmd ship with terminals that have rough Unicode + key-binding support. **Prefer Git Bash on Windows**, which is much better behaved.
+3. If it still looks broken, ask GA itself to fix it:
+ > *"My experience using `frontends/tui_v3.py` in PowerShell / cmd / Git Bash on Windows is very poor — lots of incompatibility. Please refer to Claude Code's best practices for the Windows terminal and fix all font and rendering incompatibilities."*
-| | GenericAgent | OpenClaw | Claude Code |
-|---|---|---|---|
-| Codebase | ~3,300 lines | ~530,000 lines | Open-source (large) |
-| Deploy | `pip install` + API key | Multi-service orchestration | CLI + subscription |
-| Browser | Injects into real browser (keeps login state) | Sandboxed/headless | Via MCP plugins |
-| OS Control | Keyboard, mouse, vision, ADB | Multi-agent delegation | File + terminal |
-| Self-evolution | Grows SOPs & tools autonomously | Plugin ecosystem | Stateless per session |
-| Core shipped | 10 .py + 5 SOPs | Hundreds of modules | Rich CLI toolkit |
+
-## How It Works
+#### Streamlit UI
+```bash
+python launch.pyw
```
-User instruction
- ↓
-┌─────────────────────┐
-│ agent_loop.py (92L) │ ← Sense-Think-Act cycle
-│ "What do I know? │
-│ What should I do?" │
-└────────┬────────────┘
- ↓
-┌─────────────────────┐
-│ 7 Atomic Tools │ ← All capabilities derive from these
-│ code_run │ Execute any Python/PowerShell
-│ file_read/write │ Direct disk access
-│ file_patch │ Surgical code edits
-│ web_scan │ Read live web pages
-│ web_execute_js │ Control browser DOM
-│ ask_user │ Human-in-the-loop
-└────────┬────────────┘
- ↓
-┌─────────────────────┐
-│ Memory System │ ← Persistent across sessions
-│ L0: Meta-SOP │ How to manage memory itself
-│ L2: Global Facts │ Environment, credentials, paths
-│ L3: Task SOPs │ Learned procedures (self-growing)
-└─────────────────────┘
+
+### Bot Interface (IM)
+
+GenericAgent also supports IM frontends such as Telegram, Discord, and Lark.
+
+| Platform | Command |
+| :--- | :--- |
+| Telegram | `python frontends/tgapp.py` |
+| Discord | `python frontends/dcapp.py` |
+| Lark / Feishu | `python frontends/fsapp.py` |
+
+> WeChat, QQ, WeCom and DingTalk are also supported — see the Chinese section below.
+> For detailed setup, ask GenericAgent itself.
+
+---
+
+## 🔓 Unlocking Advanced Capabilities
+
+In GA, advanced capabilities are unlocked by **instructing the agent**, not by reading
+docs or installing extras. Each instruction below makes GA read its pre-installed SOPs
+(battle-tested playbooks in its memory), install whatever is missing, adapt to your OS,
+and persist the result into its own memory.
+
+| Capability | Just tell GA |
+| :--- | :--- |
+| 🌐 Web automation | *"Set up your web automation capability."* — GA guides you through the one manual step: dragging the bundled Chrome extension into `chrome://extensions`. |
+| 🔤 OCR | *"Set up your OCR capability with rapidocr and save it to memory."* |
+| 👁️ Vision | *"Set up your vision capability from the template in memory/."* — GA copies the template, wires it to your existing LLM keys, and self-tests. |
+| 🖱️ Computer use | *"Probe this system and set up your computer-use capability."* |
+
+> 💡 **About language**: the pre-installed SOPs are written in Chinese — GA reads them
+> natively, so this never blocks you. If you prefer an English knowledge base, just say:
+> *"Read your pre-installed SOPs and rewrite them in English (keep code, paths and error
+> strings verbatim)."*
+>
+> 🌍 **About platforms**: the SOPs were honed on Windows, but cross-platform adaptation is
+> itself a GA task — on macOS/Linux, GA swaps in the platform equivalents (window
+> enumeration, input control, screenshots) on its own. Same self-evolution principle.
+
+---
+
+## 🧠 Architecture
+
+GenericAgent accomplishes complex tasks through **Layered Memory × Minimal Toolset × Autonomous Execution Loop**, continuously accumulating experience during execution.
+
+### 1️⃣ Layered Memory System
+
+> *Memory crystallizes throughout task execution, letting the agent build stable, efficient working patterns over time.*
+
+| Layer | Name | Description |
+| :---: | :--- | :--- |
+| **L0** | Meta Rules | Core behavioral rules and system constraints |
+| **L1** | Insight Index | Minimal memory index for fast routing and recall |
+| **L2** | Global Facts | Stable knowledge accumulated over long-term operation |
+| **L3** | Task Skills / SOPs | Reusable workflows for completing specific task types |
+| **L4** | Session Archive | Archived task records distilled from finished sessions for long-horizon recall |
+
+### 2️⃣ Autonomous Execution Loop
+
+> *Perceive environment state → Task reasoning → Execute tools → Write experience to memory → Loop*
+
+The entire core loop is just **~100 lines of code** ([`agent_loop.py`](agent_loop.py)).
+
+### 3️⃣ Minimal Toolset
+
+> *GenericAgent provides only **9 atomic tools**, forming the foundational capabilities for interacting with the outside world.*
+
+| Tool | Function |
+| :--- | :--- |
+| `code_run` | Execute arbitrary code (Python / PowerShell) |
+| `file_read` | Read files |
+| `file_write` | Write / create / overwrite files |
+| `file_patch` | Patch / modify files |
+| `web_scan` | Perceive web content |
+| `web_execute_js` | Control browser behavior |
+| `ask_user` | Human-in-the-loop confirmation |
+| `update_working_checkpoint` | *(memory)* Short-term working notepad |
+| `start_long_term_update` | *(memory)* Distill long-term memory |
+
+### 4️⃣ Capability Extension
+
+> *Capable of dynamically creating new tools.*
+
+Via `code_run`, GenericAgent can dynamically install Python packages, write new scripts, call external APIs, or control hardware at runtime — crystallizing temporary abilities into permanent tools.
+
+
+
+
GenericAgent Workflow Diagram
+
+
+---
+
+## 🧬 Self-Evolution Mechanism
+
+This is what fundamentally distinguishes GenericAgent from every other agent framework.
+
+```text
+[New Task]
+ │
+ ▼
+[Autonomous Exploration] ─► install deps · write scripts · debug · verify
+ │
+ ▼
+[Crystallize into Skill] ─► write to memory layer
+ │
+ ▼
+[Direct Recall on Next Similar Task]
```
-The agent starts with 7 primitive tools. Through `code_run`, it can install packages, write scripts, and interface with any hardware or API — effectively manufacturing new tools at runtime.
+| What you say | First time | Every time after |
+| :--- | :--- | :--- |
+| *"Read my WeChat messages"* | Install deps → reverse DB → write read script → save Skill | **one-line invoke** |
+| *"Give me a morning digest of Hacker News"* | Write scraper → build digest → schedule daily run → save Skill | **one-line invoke** |
+| *"Monitor stocks and alert me"* | Install `mootdx` → build selection flow → configure cron → save Skill | **one-line start** |
+| *"Send this file via Gmail"* | Configure OAuth → write send script → save Skill | **ready to use** |
-
-What Ships in the Box
+After a few weeks, your agent instance will have a skill tree no one else in the world has — all grown from 3K lines of seed code.
-**Core engine** (runs the agent):
-- `agent_loop.py` — Sense-Think-Act loop (92 lines)
-- `ga.py` — Tool definitions and execution
-- `sidercall.py` — LLM communication (multi-backend)
-- `agentmain.py` — Session orchestration
+---
-**Interface** (talk to the agent):
-- `stapp.py` — Streamlit web UI
-- `tgapp.py` — Telegram bot interface
-- `launch.pyw` — One-click launcher with floating window
+## 📊 Comparison
-**Infrastructure**:
-- `TMWebDriver.py` — Browser injection bridge (not Selenium — injects JS into your real browser via Tampermonkey)
-- `simphtml.py` — HTML→text cleaner for web perception
+| Feature | **GenericAgent** | OpenClaw | Claude Code |
+| :--- | :---: | :---: | :---: |
+| **Codebase** | ~3K lines | ~530,000 lines | Open-sourced (large) |
+| **Deployment** | `pip install` + API Key | Multi-service orchestration | CLI + subscription |
+| **Browser Control** | Real browser (session preserved) | Sandbox / headless browser | Via MCP plugin |
+| **OS Control** | Mouse/kbd, vision, ADB | Multi-agent delegation | File + terminal |
+| **Self-Evolution** | Autonomous skill growth | Plugin ecosystem | Stateless between sessions |
+| **Out of the Box** | Few core files + starter skills | Hundreds of modules | Rich CLI toolset |
-**5 Core SOPs** (shipped, version-controlled):
-1. `memory_management_sop` — L0 constitution: how the agent manages its own memory
-2. `autonomous_operation_sop` — Self-directed task execution
-3. `scheduled_task_sop` — Cron-like recurring tasks
-4. `web_setup_sop` — Browser environment bootstrap
-5. `ljqCtrl_sop` — Desktop physical control (keyboard, mouse, DPI-aware)
+---
-Everything else — Gmail integration, WeChat automation, vision APIs, game downloaders, stock analysis workflows — the agent builds and memorizes on its own through use.
+## 📈 Evaluation
-
+> 📂 Full evaluation datasets and results: [**JinyiHan99/GA-Technical-Report**](https://github.com/JinyiHan99/GA-Technical-Report/tree/main)
+
+We evaluate GenericAgent across **five dimensions**:
+
+| # | Dimension | Question | Benchmarks |
+| :---: | :--- | :--- | :--- |
+| 1 | **Task Completion & Token Efficiency** | Can GA complete hard tasks more cheaply than leading agents? | SOP-Bench, Lifelong AgentBench, RealFin-Benchmark |
+| 2 | **Tool-Use Efficiency** | Can a minimal atomic toolset solve what specialized toolsets solve, with less overhead? | Tool Efficiency Benchmark (11 simple + 5 long-horizon) |
+| 3 | **Memory System Effectiveness** | Does condensed hierarchical memory beat full/redundant memory and embedding-based retrievers? | SOP-Bench (dangerous goods), LoCoMo, 20-skill stress test |
+| 4 | **Self-Evolution Capability** | Can the agent distill experience into reusable SOPs and code, without intervention? | 9-round LangChain longitudinal study, 8-task cross-task web benchmark |
+| 5 | **Web Browsing Capability** | Does density-driven design survive the open web? | WebCanvas, BrowseComp-ZH, Custom Tasks (22) |
+
+Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and **OpenClaw**, evaluated under *Claude Sonnet 4.6*, *Claude Opus 4.6*, *GPT-5.4*, and *MiniMax M2.7* backbones.
+
+
+
+
+
+ Tool-use efficiency radar. GA dominates token, request, and tool-call axes while preserving quality across four task dimensions.
+
+
+
+ Cross-task self-evolution. Second- and third-run GA executions converge to a stable low-cost regime across eight web tasks, while OpenClaw shows no such convergence.
+
+
+
+
+### Browser Realness of GA Web Tools (TMWebdriver)
+
+GA web tools are powered by **TMWebdriver** — a local WebSocket server plus a Chrome extension — running through a **real, persistent Chrome/Chromium session** rather than a disposable headless sandbox, preserving cookies, login state, extensions, GPU/WebGL behavior, and normal browser-session fingerprints.
+
+| Detection Service / Signal | Vanilla Headless Automation | GA Web Tools | Notes |
+| :--- | :---: | :---: | :--- |
+| SannySoft headless test | Often detected | ✅ 56/56 passed | `bot.sannysoft.com` |
+| bot.incolumitas.com | Commonly fails webdriver / CDP checks | ✅ 36/36 passed | `WEBDRIVER`, `SELENIUM_DRIVER`, `webDriverAdvanced` all OK |
+| BrowserScan bot detection | Often abnormal | ✅ Normal | `browserscan.net` |
+| Device & Browser Info bot test | Multiple bot flags | ✅ Human / `isBot=false` | `deviceandbrowserinfo.com` |
+| FingerprintJS bot detection demo | Often detected | ✅ Passed | Demo flow completed without bot verdict |
+| reCAPTCHA v3 demo | Low bot-like score | ✅ 0.9 human-like score | Score-based risk signal; 0.9 is above typical production thresholds |
+
+For reCAPTCHA v3, `0.9` is not a "checkbox solved" result; it is the high-confidence human-like score returned by the risk model, typically sufficient to avoid extra challenges in production flows.
+
+---
+
+## 📅 Roadmap & News
+
+- **2026-05-23** — 🆕 **TUI v3 released** (`frontends/tui_v3.py`). Block-based scrollback with proper resize reflow, per-terminal color profile for cross-terminal parity, and feature parity with v2.
+- **2026-05-18** — 🆕 **Morphling mode**. Project-level skill absorption — extract goal + tests from any external repo, then decide per component: call, rewrite, or discard. See `memory/morphling_sop.md`.
+- **2026-05-17** — 🆕 **Goal Hive mode**. Multi-worker cooperative Goal mode — BBS-coordinated master/workers running long-horizon objectives in parallel. See `memory/goal_hive_sop.md`.
+- **2026-05-15** — 🖥️ **Desktop GUI released**. One-line installs ship a ready-to-run desktop app (`frontends/GenericAgent.exe`). Developers launch via `python launch.pyw`.
+- **2026-05-14** — 🆕 **Conductor sub-agent orchestration**. Spawn, supervise, and auto-clean parallel sub-agents; first-class delegation primitives complementing `/btw` side-questions.
+- **2026-05-12** — 🆕 **TUI v2 released** (`frontends/tuiapp_v2.py`). Refined Textual frontend with image-paste folding, file paste, block-delete, Ctrl+C copy, history navigation, and `/llm` / `/export` / `/continue` pickers.
+- **2026-05-08** — 🆕 **Goal mode** (`reflect/goal_mode.py`). Time-budget-driven self-driven loop — "keep optimizing X for N hours" with no premature delivery.
+- **2026-04-21** — 📄 [**Technical Report on arXiv**](https://arxiv.org/abs/2604.17091) — *GenericAgent: A Token-Efficient Self-Evolving LLM Agent via Contextual Information Density Maximization*.
+- **2026-04-11** — Introduced **L4 session archive memory** and scheduler cron integration.
+- **2026-03-23** — Personal WeChat supported as a bot frontend.
+- **2026-03-10** — [Released million-scale Skill Library](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7) *(Chinese)*.
+- **2026-03-08** — [Released "Dintal Claw" — a GenericAgent-powered government-affairs bot](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg) *(Chinese)*.
+- **2026-03-01** — [Featured by Jiqizhixin (机器之心)](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg) *(Chinese)*.
+- **2026-01-16** — GenericAgent **V1.0** public release.
---
-
+## ⭐ Community & Support
-# GenericAgent — 3,300 行代码,完整 OS 级自主控制
+If this project helped you, please consider leaving a **Star!** 🙏
+
+### 🚩 Friendly Links
+
+Thanks to the **LinuxDo** community for the support!
+
+[](https://linux.do/)
+
+**Community GUIs** *(independent open-source projects)*:
+
+- [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager)
+- [wangjc683/galley](https://github.com/wangjc683/galley) — Out-of-the-box local agent workbench with a bundled GA runtime (CPython 3.11 + deps), native GUI/CLI, multi-session + Project orchestration, local-first.
+- [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench)
+- [Fwind43/GenericAgent-Admin](https://github.com/Fwind43/GenericAgent-Admin) — Go + React desktop admin panel: service lifecycle management, native chat, Goal mode, BBS team board, file editor, model config wizard, TMWebDriver monitor, self-update, and Windows tray/desktop-pet integration.
+
+---
+
+## 📄 License
+
+Distributed under the **MIT License**. See [`LICENSE`](LICENSE) for full text.
+
+> *Disclaimer: The official GenericAgent channels are this GitHub repository and https://gaagent.ai. DintalClaw is currently the only officially authorized commercial partner; any other third-party website, organization, or individual using the GenericAgent name is not official unless explicitly listed here.*
+
+---
-一个极简自主 Agent 框架。用约 3,300 行 Python,让任意 LLM 获得对你 PC 的物理级控制能力——浏览器、终端、文件系统、键鼠、屏幕视觉、移动设备。
+
-不需要 Electron,不需要 Docker,不需要 Mac Mini,不需要 53 万行代码,不需要付费安装服务。
+## 🌟 项目简介
-## 用起来是什么样的
+**GenericAgent** 是一个极简、可自我进化的自主 Agent 框架。核心仅 **~3K 行代码**,通过 **9 个原子工具 + ~100 行 Agent Loop**,赋予任意 LLM 对本地计算机的系统级控制能力,覆盖浏览器、终端、文件系统、键鼠输入、屏幕视觉及移动设备(ADB)。
+> 设计哲学 —— **不预设技能,靠进化获得能力。**
+
+每解决一个新任务,GenericAgent 就将执行路径自动固化为 Skill,供后续直接调用。使用时间越长,沉淀的技能越多,形成一棵完全属于你、从 3K 行种子代码生长出来的专属技能树。
+
+> 🤖 **自举实证** — 本仓库的一切,从安装 Git、`git init` 到每一条 commit message,均由 GenericAgent 自主完成。作者全程未打开过一次终端。
+
+### 📑 目录
+
+- [核心特性](#-核心特性)
+- [实例展示](#-实例展示)
+- [快速开始](#-快速开始)
+- [使用方式](#-使用方式)
+- [架构设计](#-架构设计)
+- [自我进化机制](#-自我进化机制)
+- [与同类产品对比](#-与同类产品对比)
+- [评测](#-评测)
+- [路线图与最新动态](#-路线图与最新动态)
+- [社区与支持](#-社区与支持)
+- [许可](#-许可)
+
+---
+
+## 📋 核心特性
+
+| 特性 | 说明 |
+| :--- | :--- |
+| 🧬 **自我进化** | 每次任务自动沉淀 Skill,能力随使用持续增长,形成专属技能树 |
+| 🪶 **极简架构** | ~3K 行核心代码,Agent Loop 约百行,无复杂依赖,部署零负担 |
+| ⚡ **强执行力** | 注入真实浏览器(保留登录态),9 个原子工具直接接管系统 |
+| 🔌 **高兼容性** | 支持 Claude / Gemini / Kimi / MiniMax 等主流模型,跨平台运行 |
+| 💰 **极致省 Token** | 上下文窗口不到 30K,是其他 Agent(200K–1M)的零头;噪声更少、幻觉更低、成功率更高,成本低一个数量级 |
+
+---
+
+## 🎯 实例展示
+
+
+
+ 🧋 外卖下单
+ 📈 量化选股
+
+
+
+
+
+
+ "Order me a milk tea" — 自动导航外卖 App,选品并完成结账
+ "Find GEM stocks with EXPMA golden cross, turnover > 5%" — 量化条件筛股
+
+
+ 🌐 自主网页探索
+ 💰 支出追踪
+
+
+
+
+
+
+ 自主浏览并定时汇总网页信息
+ "查找近 3 个月超 ¥2K 的支出" — 通过 ADB 驱动支付宝
+
+
+ 💬 批量消息
+
+
+
+
+
+ 批量发送微信消息,完整驱动微信客户端
+
+
+
+---
+
+## 🚀 快速开始
+
+> ⚠️ **Python 版本:** 推荐使用 **Python 3.11 或 3.12**。**请不要使用 Python 3.14**,与 `pywebview` 及部分依赖不兼容。
+>
+> 📖 详细安装指南:**[installation_zh.md(中文)](docs/installation_zh.md)** · **[installation.md (English)](docs/installation.md)**
+
+### 给 LLM Agent 看的
+
+获取安装指南并照做:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/main/docs/installation_zh.md
+```
+
+### 给人类用户看的
+
+#### 方法一 — 一键安装 *(推荐)*
+
+一键安装会自动准备独立 Python 环境、Git、项目文件和桌面端,不污染系统环境。
+
+**Windows PowerShell**
+
+```powershell
+powershell -ExecutionPolicy Bypass -c "irm http://fudankw.cn:9000/files/ga_install.ps1 | iex"
```
-你:"帮我读取微信消息"
-Agent:安装依赖 → 逆向数据库 → 写读取脚本 → 保存为 SOP
-下次:一句话直接调用,零配置。
-你:"帮我监控股票并提醒"
-Agent:安装 mootdx → 构建选股工作流 → 设置定时任务 → 保存为 SOP
-下次:一句话启动。
+**Linux / macOS**
-你:"用 Gmail 发这个文件"
-Agent:配置 OAuth → 写发送脚本 → 保存为 SOP
-下次:直接能用。
+```bash
+curl -fsSL http://fudankw.cn:9000/files/ga_install.sh | bash
```
-**自举实证**:本仓库从安装 Git、`git init`、编写 README 到每一条 commit message,全程由 GenericAgent 完成——作者没有打开过一次终端。
+安装完成后启动:
-每个解决过的任务都会变成永久技能。用几周后,你的 Agent 实例会拥有一套独特的技能树——全部从 3,300 行种子代码中生长出来。
+- **Windows** — 双击 `frontends/GenericAgent.exe`
+- **Linux / macOS** — 在安装目录运行 `python launch.pyw`
-## 自举哲学
+#### 方法二 — Python 安装 *(开发者)*
-多数 Agent 框架以成品形态发布。GenericAgent 以**种子**形态发布。
+```bash
+git clone https://github.com/lsdefine/GenericAgent.git
+cd GenericAgent
+uv venv
+uv pip install -e ".[ui]" # 核心 + UI 依赖
+cp mykey_template.py mykey.py # 填入你的 LLM API Key
+python launch.pyw
+```
-5 个核心 SOP 定义了 Agent 如何思考、记忆和行动。之后的一切能力,由 Agent 在使用中自主发现并记录:
+> 💡 GenericAgent 更推荐由 **Agent 在使用中自举环境**,而不是预先手动装完整依赖。
-1. 你让它做一件新事
-2. 它自己摸索方法(安装依赖、写脚本、测试)
-3. 把流程保存为新 SOP
-4. 下次直接调用
+📖 完整引导流程见 [`docs/GETTING_STARTED.md`](docs/GETTING_STARTED.md)
+📖 新手图文版:[飞书文档](https://my.feishu.cn/wiki/CGrDw0T76iNFuskmwxdcWrpinPb)
+📘 完整入门教程(Datawhale 出品):[Hello GenericAgent](https://datawhalechina.github.io/hello-generic-agent/) · [GitHub](https://github.com/datawhalechina/hello-generic-agent)
-Agent 不只是执行——它**学习并记忆**。
+---
+
+## 💻 使用方式
+
+### 前端启动
-## 快速开始
+#### 桌面端
+
+一键安装自带桌面端(Windows),双击:
+
+```text
+frontends/GenericAgent.exe
+```
+
+#### 终端 UI
+
+基于 [Textual](https://github.com/Textualize/textual) 的轻量键盘驱动界面。支持多会话并发、实时流式输出,有终端就能跑。
```bash
-# 1. 克隆
-git clone https://github.com/lsdefine/pc-agent-loop.git
-cd pc-agent-loop
+python frontends/tuiapp_v2.py
+```
+
+
+⚠️ Windows 上 TUI 显示异常的排查思路
+
+1. `textual` 版本太旧,先 `pip install -U textual`;
+2. PowerShell / cmd 自带终端对 Unicode 和键位的支持比较糟糕,**Windows 上推荐用 Git Bash**,体验明显更稳;
+3. 仍然显示异常时,可以让 GA 自己修一遍,参考 Prompt:
+ > *"我在 Windows 的 PowerShell / cmd / Git Bash 中使用 `frontends/tuiapp_v2.py` 体验非常差,出现了一堆不兼容问题。请参考 Claude Code 在 Windows 终端的最佳配置,把所有字体和显示不兼容的问题修一遍。"*
-# 2. 安装最小依赖
-pip install streamlit pywebview
+
-# 3. 配置 API Key
-cp mykey_template.py mykey.py
-# 编辑 mykey.py 填入你的 LLM API Key
+#### Streamlit UI
-# 4. 启动
+```bash
python launch.pyw
```
-**同样可在 Android 上运行** — 已在 Termux 上测试通过,通过 `python agentmain.py`(CLI 前端)启动:
+### Bot 接口(IM)
-```bash
-# 在 Termux 中
-cd /sdcard/ga
-python agentmain.py
+GenericAgent 支持 Telegram、Discord、微信、QQ、飞书 / Lark、企业微信、钉钉等 IM 前端。
+
+| 平台 | 启动命令 |
+| :--- | :--- |
+| Telegram | `python frontends/tgapp.py` |
+| Discord | `python frontends/dcapp.py` |
+| 微信 | `python frontends/wechatapp.py` |
+| QQ | `python frontends/qqapp.py` |
+| 飞书 / Lark | `python frontends/fsapp.py` |
+| 企业微信 | `python frontends/wecomapp.py` |
+| 钉钉 | `python frontends/dingtalkapp.py` |
+
+> 详细配置直接问 GenericAgent。
+
+---
+
+## 🧠 架构设计
+
+GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环** 完成复杂任务,并在执行过程中持续积累经验。
+
+### 1️⃣ 分层记忆系统
+
+> *记忆在任务执行过程中持续沉淀,使 Agent 逐步形成稳定且高效的工作方式。*
+
+| 层级 | 名称 | 说明 |
+| :---: | :--- | :--- |
+| **L0** | 元规则(Meta Rules) | Agent 的基础行为规则和系统约束 |
+| **L1** | 记忆索引(Insight Index) | 极简索引层,用于快速路由与召回 |
+| **L2** | 全局事实(Global Facts) | 在长期运行过程中积累的稳定知识 |
+| **L3** | 任务 Skills / SOPs | 完成特定任务类型的可复用流程 |
+| **L4** | 会话归档(Session Archive) | 从已完成任务中提炼出的归档记录,用于长程召回 |
+
+### 2️⃣ 自主执行循环
+
+> *感知环境状态 → 任务推理 → 调用工具执行 → 经验写入记忆 → 循环*
+
+整个核心循环仅 **约百行代码**([`agent_loop.py`](agent_loop.py))。
+
+### 3️⃣ 最小工具集
+
+> *GenericAgent 仅提供 **9 个原子工具**,构成与外部世界交互的基础能力。*
+
+| 工具 | 功能 |
+| :--- | :--- |
+| `code_run` | 执行任意代码(Python / PowerShell) |
+| `file_read` | 读取文件 |
+| `file_write` | 写入 / 创建 / 覆盖文件 |
+| `file_patch` | 修改文件 |
+| `web_scan` | 感知网页内容 |
+| `web_execute_js` | 控制浏览器行为 |
+| `ask_user` | 人机协作确认 |
+| `update_working_checkpoint` | *(记忆)* 短期工作记事板 |
+| `start_long_term_update` | *(记忆)* 提炼长期记忆 |
+
+### 4️⃣ 能力扩展机制
+
+> *具备动态创建新工具的能力。*
+
+通过 `code_run`,GenericAgent 可在运行时动态安装 Python 包、编写新脚本、调用外部 API 或控制硬件,将临时能力固化为永久工具。
+
+
+
+
GenericAgent 工作流程图
+
+
+---
+
+## 🧬 自我进化机制
+
+这是 GenericAgent 区别于其他 Agent 框架的根本所在。
+
+```text
+[遇到新任务]
+ │
+ ▼
+[自主摸索] ─► 安装依赖 · 编写脚本 · 调试验证
+ │
+ ▼
+[执行路径固化为 Skill] ─► 写入记忆层
+ │
+ ▼
+[下次同类任务直接调用]
```
-启动后告诉 Agent:"执行 web setup SOP 解锁浏览器工具"——剩下的它自己搞定。完整引导流程见 [WELCOME_NEW_USER.md](WELCOME_NEW_USER.md)。
+| 你说的一句话 | 第一次做了什么 | 之后每次 |
+| :--- | :--- | :--- |
+| *"监控股票并提醒我"* | 安装 `mootdx` → 构建选股流程 → 配置定时任务 → 保存 Skill | **一句话启动** |
+| *"用 Gmail 发这个文件"* | 配置 OAuth → 编写发送脚本 → 保存 Skill | **直接可用** |
-## 对比
+用几周后,你的 Agent 实例将拥有一套任何人都没有的专属技能树,全部从 3K 行种子代码中生长而来。
-| | GenericAgent | OpenClaw | Claude Code |
-|---|---|---|---|
-| 代码量 | ~3,300 行 | ~530,000 行 | 已开源(体量大) |
-| 部署 | `pip install` + API key | 多服务编排 | CLI + 订阅 |
-| 浏览器 | 注入真实浏览器(保留登录态) | 沙箱/无头浏览器 | 通过 MCP 插件 |
-| OS 控制 | 键鼠、视觉、ADB | 多 Agent 委派 | 文件 + 终端 |
-| 自我进化 | 自主生长 SOP 和工具 | 插件生态 | 会话间无状态 |
-| 出厂配置 | 10 个 .py + 5 个 SOP | 数百模块 | 丰富 CLI 工具集 |
+---
-## 工作原理
+## 📊 与同类产品对比
-Agent 拥有 7 个原子工具:`code_run`(执行任意代码)、`file_read/write/patch`(文件操作)、`web_scan`(网页感知)、`web_execute_js`(浏览器控制)、`ask_user`(人机协作)。
+| 特性 | **GenericAgent** | OpenClaw | Claude Code |
+| :--- | :---: | :---: | :---: |
+| **代码量** | ~3K 行 | ~530,000 行 | 已开源(体量大) |
+| **部署方式** | `pip install` + API Key | 多服务编排 | CLI + 订阅 |
+| **浏览器控制** | 注入真实浏览器(保留登录态) | 沙箱 / 无头浏览器 | 通过 MCP 插件 |
+| **OS 控制** | 键鼠、视觉、ADB | 多 Agent 委派 | 文件 + 终端 |
+| **自我进化** | 自主生长 Skill 和工具 | 插件生态 | 会话间无状态 |
+| **出厂配置** | 几个核心文件 + 少量初始 Skills | 数百模块 | 丰富 CLI 工具集 |
-通过 `code_run`,它可以安装任何包、编写任何脚本、对接任何硬件——相当于在运行时制造新工具。学到的流程保存为 SOP,下次直接调用。
+---
-核心循环只有 92 行(`agent_loop.py`):感知 → 思考 → 行动 → 记忆。
+## 📈 评测
-
-出厂清单
+> 📂 完整的评测数据集以及评测结果见:[**JinyiHan99/GA-Technical-Report**](https://github.com/JinyiHan99/GA-Technical-Report/tree/main)
-**核心引擎**:
-- `agent_loop.py` — 感知-思考-行动循环(92 行)
-- `ga.py` — 工具定义与执行
-- `sidercall.py` — LLM 通信(多后端)
-- `agentmain.py` — 会话编排
+我们从 **五大维度** 评测 GenericAgent:
-**交互界面**:
-- `stapp.py` — Streamlit Web UI
-- `tgapp.py` — Telegram 机器人
-- `launch.pyw` — 一键启动 + 悬浮窗
+| # | 维度 | 核心问题 | 使用的基准 |
+| :---: | :--- | :--- | :--- |
+| 1 | **任务完成度与 Token 效率** | GA 能否以更低成本完成高难度任务? | SOP-Bench、Lifelong AgentBench、RealFin-Benchmark |
+| 2 | **工具使用效率** | 最小原子工具集能否以更低开销替代专用工具集? | Tool Efficiency Benchmark |
+| 3 | **记忆系统有效性** | 精简分层记忆能否超越冗余记忆和基于 Embedding 的检索器? | SOP-Bench、LoCoMo、20-skill 压力测试 |
+| 4 | **自我进化能力** | Agent 能否在无人干预下将经验提炼为可复用的 SOP 与代码? | 9 轮 LangChain 纵向研究、8 任务跨任务 Web 基准 |
+| 5 | **网页浏览能力** | 信息密度驱动设计能否适应开放网页? | WebCanvas、BrowseComp-ZH、自定义任务 |
-**基础设施**:
-- `TMWebDriver.py` — 浏览器注入桥接(非 Selenium,通过 Tampermonkey 注入真实浏览器)
-- `simphtml.py` — HTML→文本清洗
+以上维度的基线包括 **Claude Code**、**OpenAI CodeX** 和 **OpenClaw**,分别在 *Claude Sonnet 4.6*、*Claude Opus 4.6*、*GPT-5.4* 和 *MiniMax M2.7* 底座上进行评测。
-**5 个核心 SOP**(出厂自带,版本控制):
-1. `memory_management_sop` — L0 宪法:Agent 如何管理自身记忆
-2. `autonomous_operation_sop` — 自主任务执行
-3. `scheduled_task_sop` — 定时任务
-4. `web_setup_sop` — 浏览器环境引导
-5. `ljqCtrl_sop` — 桌面物理控制(键鼠、DPI 感知)
+
+
+
+
+ 工具使用效率雷达图。 GA 在 Token、请求数和工具调用轴上全面领先,同时在四个任务维度上保持质量。
+
+
+
+ 跨任务自我进化。 GA 的第二轮和第三轮执行在 8 个 Web 任务上收敛至稳定的低成本区间。
+
+
+
-其余一切——Gmail、微信自动化、视觉 API、游戏下载、股票分析——都是 Agent 在使用中自主构建并记忆的。
+### GA Web 工具的浏览器真实性
-
+GA Web 工具运行在**真实、持久化的 Chrome/Chromium 会话**中,而不是一次性的 headless 沙箱,因此可以保留 Cookie、登录态、扩展、GPU/WebGL 行为以及正常浏览器会话指纹。
+
+| 检测服务 / 信号 | 普通 Headless 自动化 | GA Web 工具 | 说明 |
+| :--- | :---: | :---: | :--- |
+| SannySoft headless test | 常被识别 | ✅ 56/56 通过 | `bot.sannysoft.com` |
+| bot.incolumitas.com | 常在 webdriver / CDP 项异常 | ✅ 36/36 通过 | `WEBDRIVER`、`SELENIUM_DRIVER`、`webDriverAdvanced` 全部 OK |
+| BrowserScan bot detection | 常显示异常 | ✅ Normal | `browserscan.net` |
+| Device & Browser Info bot test | 多个 bot 标记 | ✅ Human / `isBot=false` | `deviceandbrowserinfo.com` |
+| FingerprintJS bot detection demo | 常被识别 | ✅ 通过 | Demo 流程完成,未给出 bot 判定 |
+| reCAPTCHA v3 demo | 低分 / bot-like | ✅ 0.9 真人相似分 | v3 是基于分数的风险信号;0.9 高于常见生产阈值 |
+
+对于 reCAPTCHA v3,`0.9` 不是“点过验证码”的结果,而是风控模型返回的高置信真人相似分,通常足以通过生产环境中的常见阈值,避免进入更严格挑战。
+
+---
+
+## 📅 路线图与最新动态
+
+- **2026-05-23** — 🆕 **TUI v3 正式发布**(`frontends/tui_v3.py`)。基于块的滚屏回看 + 正确的 resize 重排,每终端独立配色保证跨终端一致,并与 v2 达成功能对齐。
+- **2026-05-18** — 🆕 **Morphling 模式**。项目级能力吞噬 —— 从任意外部仓库抽取目标与测例后,对每个核心组件分别决定调用、重写或舍弃。详见 `memory/morphling_sop.md`。
+- **2026-05-17** — 🆕 **Goal Hive 模式**。多 worker 协作版 Goal —— Master/Worker 通过 BBS 协同推进长程目标。详见 `memory/goal_hive_sop.md`。
+- **2026-05-15** — 🖥️ **桌面 GUI 发布**。一键安装会自带可直接运行的桌面端(`frontends/GenericAgent.exe`),开发者也可用 `python launch.pyw` 启动。
+- **2026-05-14** — 🆕 **Conductor 子 Agent 编排**。派发、监督、自动清理并行子 Agent;与 `/btw` 旁路子 Agent 互补,提供一等公民级的任务委派原语。
+- **2026-05-12** — 🆕 **TUI v2 正式发布**(`frontends/tuiapp_v2.py`)。重做视觉风格的 Textual 前端,支持图片粘贴折叠、文件粘贴、块删除、Ctrl+C 复制、历史导航,以及 `/llm` / `/export` / `/continue` 选择器。
+- **2026-05-08** — 🆕 **Goal 模式**(`reflect/goal_mode.py`)。时间预算驱动的自驱循环 —— "持续优化 X N 小时",预算没到不准提前交付。
+- **2026-04-21** — 📄 [**技术报告已发布至 arXiv**](https://arxiv.org/abs/2604.17091) — *GenericAgent: A Token-Efficient Self-Evolving LLM Agent via Contextual Information Density Maximization*。
+- **2026-04-11** — 引入 **L4 会话归档记忆**,并接入 scheduler cron 调度。
+- **2026-03-23** — 支持个人微信接入作为 Bot 前端。
+- **2026-03-10** — [发布百万级 Skill 库](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7)。
+- **2026-03-08** — [发布以 GenericAgent 为核心的"政务龙虾" Dintal Claw](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg)。
+- **2026-03-01** — [被机器之心报道](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg)。
+- **2026-01-16** — GenericAgent **V1.0** 公开版本发布。
+
+---
+
+## ⭐ 社区与支持
+
+如果这个项目对你有帮助,欢迎点一个 **Star!** 🙏
+
+也欢迎加入 **GenericAgent 体验交流群**,一起交流、反馈、共建 👏
+
+
+
+
+ 微信群 21
+
+
+
+
+### 🚩 友情链接
+
+感谢 **LinuxDo** 社区的支持!
+
+[](https://linux.do/)
+
+**社区 GUI 客户端** *(独立开源项目)*:
+
+- [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager)
+- [wangjc683/galley](https://github.com/wangjc683/galley) —— 开箱即用的本地 Agent 工作台,自带 GA 内核(内置 CPython 3.11 + 运行依赖),GUI/CLI 双原生、多 session + Project 编排、本地优先。
+- [FroStorM/A3Agent](https://github.com/FroStorM/A3Agent/tree/workbench)
+- [Fwind43/GenericAgent-Admin](https://github.com/Fwind43/GenericAgent-Admin) —— Go + React 桌面管理面板:服务生命周期管理、原生 Chat、Goal 模式、BBS 团队看板、文件编辑器、模型配置向导、TMWebDriver 监控、自更新,以及 Windows 托盘/桌面宠物集成。
+
+---
+
+## 📄 许可
+
+基于 **MIT License** 发布,详见 [`LICENSE`](LICENSE)。
+
+> *声明:GenericAgent 官方渠道为本 GitHub 仓库和 https://gaagent.ai。DintalClaw 是目前唯一官方授权的商业合作方;除非在此处明确列出,其他使用 GenericAgent 名义的第三方网站、机构、组织或个人均非官方。*
+
+---
+
+## 📈 Star History
+
+
-## 许可
+
+
+
+
+
+
+
-MIT
\ No newline at end of file
+
+
diff --git a/TMWebDriver.py b/TMWebDriver.py
index 86a2b0b05..c14535a18 100644
--- a/TMWebDriver.py
+++ b/TMWebDriver.py
@@ -1,9 +1,8 @@
import json, threading, time, uuid, queue, socket, requests, traceback
-from typing import Dict, Any, Optional, List
-from simple_websocket_server import WebSocketServer, WebSocket
-from bs4 import BeautifulSoup
-import bottle, random
-from bottle import route, template, request, response
+from typing import Any
+from simple_websocket_server import WebSocketServer, WebSocket
+import bottle
+from bottle import request
class Session:
def __init__(self, session_id, info, client=None):
@@ -12,7 +11,7 @@ def __init__(self, session_id, info, client=None):
self.connect_at = time.time()
self.disconnect_at = None
self.type = info.get('type', 'ws')
- self.ws_client = client if self.type == 'ws' else None
+ self.ws_client = client if self.type in ('ws', 'ext_ws') else None
self.http_queue = client if self.type == 'http' else None
@property
def url(self): return self.info.get('url', '')
@@ -22,7 +21,7 @@ def is_active(self):
def reconnect(self, client, info):
self.info = info
self.type = info.get('type', 'ws')
- if self.type == 'ws':
+ if self.type in ('ws', 'ext_ws'):
self.ws_client = client
self.http_queue = None
elif self.type == 'http':
@@ -30,11 +29,12 @@ def reconnect(self, client, info):
self.connect_at = time.time()
self.disconnect_at = None
def mark_disconnected(self):
+ if self.disconnect_at is None: print(f"Tab disconnected: {self.url} (Session: {self.id})")
self.disconnect_at = time.time()
class TMWebDriver:
- def __init__(self, host: str = 'localhost', port: int = 18765):
+ def __init__(self, host: str = '127.0.0.1', port: int = 18765):
self.host, self.port = host, port
self.sessions, self.results, self.acks = {}, {}, {}
self.default_session_id = None
@@ -68,7 +68,7 @@ def long_poll():
try:
msg = msgQ.get(timeout=0.2)
try: self.acks[json.loads(msg).get('id','')] = True
- except: traceback.print_exc()
+ except Exception: traceback.print_exc()
return msg
except queue.Empty: continue
return json.dumps({"id": "", "ret": "next long-poll"})
@@ -79,7 +79,7 @@ def result():
if data.get('type') == 'result':
self.results[data.get('id')] = {'success': True, 'data': data.get('result'), 'newTabs': data.get('newTabs', [])}
elif data.get('type') == 'error':
- self.results[data.get('id')] = {'success': False, 'data': data.get('error')}
+ self.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])}
return 'ok'
@app.route('/link', method=['GET','POST'])
@@ -93,12 +93,11 @@ def link():
session_id = data.get('sessionId')
code = data.get('code')
timeout = float(data.get('timeout', 10.0))
- try:
- result = self.execute_js(code, timeout=timeout, session_id=session_id)
- print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' '))
- return json.dumps({'r': result}, ensure_ascii=False)
- except Exception as e:
- return json.dumps({'error': str(e)}, ensure_ascii=False)
+ try: result = self.execute_js(code, timeout=timeout, session_id=session_id)
+ except Exception as e: return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False)
+ try: print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' '))
+ except Exception: pass
+ return json.dumps({'r': result}, ensure_ascii=False)
return 'ok'
def run():
from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler
@@ -128,16 +127,32 @@ def handle(self) -> None:
session_info = {'url': data.get('url'), 'title': data.get('title', ''),
'connected_at': time.time(), 'type': 'ws'}
driver._register_client(session_id, self, session_info)
+ elif data.get('type') in ['ext_ready', 'tabs_update']:
+ tabs = data.get('tabs', [])
+ current_tab_ids = {str(tab['id']) for tab in tabs}
+ print(f"Received tabs update: {current_tab_ids}")
+ for sid in list(driver.sessions.keys()):
+ sess = driver.sessions[sid]
+ if sess.type == 'ext_ws' and sid not in current_tab_ids:
+ sess.mark_disconnected()
+ for tab in tabs:
+ session_id = str(tab['id'])
+ session_info = {'url': tab.get('url'), 'title': tab.get('title', ''), 'connected_at': time.time(), 'type': 'ext_ws'}
+ sess = driver.sessions.get(session_id)
+ if sess and sess.is_active(): sess.info = session_info
+ else: driver._register_client(session_id, self, session_info)
elif data.get('type') == 'ack': driver.acks[data.get('id','')] = True
elif data.get('type') == 'result':
driver.results[data.get('id')] = {'success': True, 'data': data.get('result'), 'newTabs': data.get('newTabs', [])}
elif data.get('type') == 'error':
- driver.results[data.get('id')] = {'success': False, 'data': data.get('error')}
+ driver.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])}
except Exception as e:
print(f"Error handling message: {e}")
if hasattr(self, 'data'): print(self.data)
def connected(self): (f"New connection from {self.address}")
- def handle_close(self): driver._unregister_client(self)
+ def handle_close(self):
+ print(f"WS Connection closed: {self.address}")
+ driver._unregister_client(self)
self.server = WebSocketServer(self.host, self.port, JSExecutor)
server_thread = threading.Thread(target=self.server.serve_forever)
@@ -159,13 +174,10 @@ def _register_client(self, session_id: str, client: WebSocket, session_info) ->
self.latest_session_id = session_id
if self.default_session_id is None: self.default_session_id = session_id
-
def _unregister_client(self, client: WebSocket) -> None:
for session in self.sessions.values():
- if session.ws_client == client:
- session.mark_disconnected()
- break
+ if session.ws_client == client: session.mark_disconnected()
def execute_js(self, code, timeout=15, session_id=None) -> Any:
if session_id is None: session_id = self.default_session_id
@@ -190,11 +202,14 @@ def execute_js(self, code, timeout=15, session_id=None) -> Any:
raise ValueError(f"会话ID {session_id} 未连接")
tp = session.type
- assert tp in ['ws', 'http'], f"Unsupported session type: {tp}"
+ if tp not in ('ws', 'http', 'ext_ws'):
+ raise ValueError(f"Unsupported session type: {tp}")
exec_id = str(uuid.uuid4())
- payload = json.dumps({'id': exec_id, 'code': code})
+ payload_dict = {'id': exec_id, 'code': code}
+ if tp == 'ext_ws': payload_dict['tabId'] = int(session.id)
+ payload = json.dumps(payload_dict)
- if tp == 'ws': session.ws_client.send_message(payload)
+ if tp in ['ws', 'ext_ws']: session.ws_client.send_message(payload)
elif tp == 'http': session.http_queue.put(payload)
start_time = time.time()
@@ -202,15 +217,15 @@ def execute_js(self, code, timeout=15, session_id=None) -> Any:
hasjump = acked = False
while exec_id not in self.results:
- time.sleep(0.5)
+ time.sleep(0.2)
if not acked and exec_id in self.acks:
acked = True; start_time = time.time()
- if tp == 'ws':
+ if tp in ['ws', 'ext_ws']:
if not session.is_active(): hasjump = True
if hasjump and session.is_active():
return {'result': f"Session {session_id} reloaded.", "closed":1}
if time.time() - start_time > timeout:
- if tp == 'ws':
+ if tp in ['ws', 'ext_ws']:
if hasjump: return {'result': f"Session {session_id} reloaded and new page is loading...", 'closed':1}
if acked: return {"result": f"No response data in {timeout}s (ACK received, script may still be running)"}
return {"result": f"No response data in {timeout}s (no ACK, script may not have been delivered)"}
@@ -227,7 +242,9 @@ def execute_js(self, code, timeout=15, session_id=None) -> Any:
return rr
def _remote_cmd(self, cmd):
- return requests.post(self.remote, headers={"Content-Type": "application/json"}, json=cmd).json()
+ try: return requests.post(self.remote, headers={"Content-Type": "application/json"}, json=cmd, timeout=30).json()
+ except (ConnectionError, requests.exceptions.ConnectionError):
+ raise ConnectionError("TMWebDriver master未运行,看tmwebdriver_sop后台启动一个TMWebDriver")
def get_all_sessions(self):
if self.is_remote:
@@ -261,9 +278,6 @@ def set_session(self, url_pattern: str) -> bool:
return self.default_session_id
def jump(self, url, timeout=10): self.execute_js(f"window.location.href='{url}'", timeout=timeout)
- def newtab(self, url=None):
- if url is None: url = "http://www.baidu.com/robots.txt"
- return self.execute_js(f'GM_openInTab("{url}");')
if __name__ == "__main__":
- driver = TMWebDriver(host='localhost', port=18765)
\ No newline at end of file
+ driver = TMWebDriver(host='127.0.0.1', port=18765)
\ No newline at end of file
diff --git a/WELCOME_NEW_USER.md b/WELCOME_NEW_USER.md
deleted file mode 100644
index 18403527b..000000000
--- a/WELCOME_NEW_USER.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# 🚀 欢迎使用物理级全能执行者
-
-这是您的 Agent 初始化指引。请按以下阶段操作:
-
-## 第一阶段:环境启动 (Initial Ignition)
-
-1. **Python 环境检查与核心库安装**
- - 确保安装了 Python 3.10+。
- - **必须手动执行**:`pip install streamlit pywebview`
-
-2. **配置身份密钥 (Credentials)**
- - 复制 `mykey_template.py` 为 `mykey.py` 并填入 API Key。
-
-3. **唤醒 Agent**
- - 运行 `launch.pyw`。看到悬浮窗后,我即刻上线。
-
-## 第二阶段:能力激活 (Ability Activation)
-
-在此阶段,您只需对我发送指令,所有物理操作由我完成:
-
-1. **解锁 PowerShell 脚本执行权限**
- - **指令**:`请帮我当前用户解锁 powershell 的 ps1 执行权限。`
-
-2. **配置全局文件搜索 (Everything CLI)**
- - **指令**:`安装并配置 everything 命令行工具进PATH。`
-
-3. **Web 自动化环境配置 (Web Setup SOP)**
- - **指令**:`执行 web setup sop 解锁 web 工具`
- - **物理影响**:我将引导您完成浏览器插件安装,并注入核心脚本,使我能够直接操控您的浏览器页面。
-
-4. **补全常用工具库**
- - **指令**:`安装常用 Python 自动化包(如 requests, pandas, pyperclip)。`
-
-5. **配置网络代理 (Proxy Setup)**
- - **指令**:`告诉我能用的系统代理`
-
-6. **激活视觉理解能力 (OCR & Vision)**
- - **指令**:`配置截图与 OCR 工具,解锁你的屏幕视觉。`
-
-7. **移动端自动化准备 (Android/ADB)**
- - **指令**:`配置 ADB 环境,准备连接安卓设备。`
-
-## 第三阶段:记忆与知识体系建造 (Knowledge Architecture)
-
-当环境就绪后,您可以让我构建您的“数字大脑”:
-
-1. **自动化 SOP 沉淀**
- - **指令**:`记录刚才的操作流程,生成一套自动化 SOP。`
-
-2. **现有技能挂载 (SOP Retrieval)**
- - **指令**:`读取现有 SOP 目录,告诉我你现在掌握的所有技能。`
-
-3. **物理资产审计 (Asset Audit)**
- - **指令**:`帮我建立物理资产清单,扫描并记录我常用的工具路径。`
-
-4. **Web 调研实战 (Research & Report)**
- - **指令**:`搜索 [关键词],并根据网页内容整理一份简易 Markdown 报告保存到当前目录。`
- - **物理影响**:我将自动打开浏览器,利用 Web 驱动采集多个页面信息,通过逻辑整合后在您的本地文件夹生成物理文件。
-
----
-**💡 提示**:您可以直接复制上述 `指令` 发送给我,我将立刻执行对应的物理操作。
diff --git a/agent_loop.py b/agent_loop.py
index 25200e94e..3574e8e4b 100644
--- a/agent_loop.py
+++ b/agent_loop.py
@@ -1,38 +1,34 @@
-import json, re
+import json, re, os
from dataclasses import dataclass
from typing import Any, Optional
+try: from plugins.hooks import trigger as _hook
+except ImportError: _hook = lambda *a, **k: None
@dataclass
class StepOutcome:
data: Any
next_prompt: Optional[str] = None
should_exit: bool = False
-
def try_call_generator(func, *args, **kwargs):
ret = func(*args, **kwargs)
- if hasattr(ret, '__iter__') and not isinstance(ret, (str, bytes, dict, list)):
- ret = yield from ret
+ if hasattr(ret, '__iter__') and not isinstance(ret, (str, bytes, dict, list)): ret = yield from ret
return ret
class BaseHandler:
- def tool_before_callback(self, tool_name, args, response): pass
- def tool_after_callback(self, tool_name, args, response, ret): pass
- def dispatch(self, tool_name, args, response):
+ def turn_end_callback(self, response, tool_calls, tool_results, turn, next_prompt, exit_reason): return next_prompt
+ def dispatch(self, tool_name, args, response, index=0, tool_num=1):
method_name = f"do_{tool_name}"
if hasattr(self, method_name):
- _ = yield from try_call_generator(self.tool_before_callback, tool_name, args, response)
+ args['_index'] = index; args['_tool_num'] = tool_num
+ _hook('tool_before', locals())
ret = yield from try_call_generator(getattr(self, method_name), args, response)
- _ = yield from try_call_generator(self.tool_after_callback, tool_name, args, response, ret)
+ _hook('tool_after', locals())
return ret
- elif tool_name == 'bad_json':
- return StepOutcome(None, next_prompt=args.get('msg', 'bad_json'), should_exit=False)
+ elif tool_name == 'bad_json': return StepOutcome(None, next_prompt=args.get('msg', 'bad_json'), should_exit=False)
else:
yield f"未知工具: {tool_name}\n"
return StepOutcome(None, next_prompt=f"未知工具 {tool_name}", should_exit=False)
-def json_default(o):
- if isinstance(o, set): return list(o)
- return str(o)
-
+def json_default(o): return list(o) if isinstance(o, set) else str(o)
def exhaust(g):
try:
while True: next(g)
@@ -40,54 +36,98 @@ def exhaust(g):
def get_pretty_json(data):
if isinstance(data, dict) and "script" in data:
- data = data.copy()
- data["script"] = data["script"].replace("; ", ";\n ")
+ data = data.copy(); data["script"] = data["script"].replace("; ", ";\n ")
return json.dumps(data, indent=2, ensure_ascii=False).replace('\\n', '\n')
-def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, max_turns=15, verbose=True):
+def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema,
+ max_turns=40, verbose=True, initial_user_content=None, yield_info=False):
messages = [
{"role": "system", "content": system_prompt},
- {"role": "user", "content": user_input}
+ {"role": "user", "content": initial_user_content if initial_user_content is not None else user_input}
]
- for turn in range(max_turns):
- yield f"**LLM Running (Turn {turn+1}) ...**\n\n"
- if (turn+1) % 10 == 0: client.last_tools = '' # 每10轮重置一次工具描述,避免上下文过大导致的模型性能下降
+ turn = 0; handler.max_turns = max_turns
+ _hook('agent_before', locals())
+ while turn < handler.max_turns:
+ turn += 1; turnstr = f'LLM Running (Turn {turn}) ...'
+ if handler.parent.task_dir: turnstr = f'Turn {turn} ...'
+ if verbose: turnstr = f'**{turnstr}**'
+ if yield_info: yield {'turn': turn}
+ yield f"\n{turnstr}\n\n"
+ if turn%10 == 0: client.last_tools = '' # 每10轮重置一次工具描述
+ _hook('turn_before', locals())
+ _hook('llm_before', locals())
response_gen = client.chat(messages=messages, tools=tools_schema)
- response = yield from response_gen
- if verbose: yield '\n\n'
-
- if not response.tool_calls:
- tool_name, args = 'no_tool', {}
- else:
- tool_call = response.tool_calls[0]
- tool_name = tool_call.function.name
- args = json.loads(tool_call.function.arguments)
-
- if tool_name == 'no_tool': pass
- else:
- showarg = get_pretty_json(args)
- if not verbose and len(showarg) > 200: showarg = showarg[:200] + ' ...'
- yield f"🛠️ **正在调用工具:** `{tool_name}` 📥**参数:**\n````text\n{showarg}\n````\n"
- gen = handler.dispatch(tool_name, args, response)
if verbose:
- yield '`````\n'
- outcome = yield from gen
- yield '`````\n'
+ response = yield from response_gen
+ yield '\n\n'
else:
- outcome = exhaust(gen)
+ response = exhaust(response_gen)
+ cleaned = _clean_content(response.content)
+ if cleaned: yield cleaned + '\n'
+ _hook('llm_after', locals())
+
+ if not response.tool_calls: tool_calls = [{'tool_name': 'no_tool', 'args': {}}]
+ else: tool_calls = [{'tool_name': tc.function.name, 'args': json.loads(tc.function.arguments), 'id': tc.id}
+ for tc in response.tool_calls]
+
+ tool_results = []; next_prompts = set(); exit_reason = {}
+ for ii, tc in enumerate(tool_calls):
+ tool_name, args, tid = tc['tool_name'], tc['args'], tc.get('id', '')
+ if tool_name == 'no_tool': pass
+ else:
+ if verbose: yield f"🛠️ Tool: `{tool_name}` 📥 args:\n````text\n{get_pretty_json(args)}\n````\n"
+ else: yield f"🛠️ {tool_name}({_compact_tool_args(tool_name, args)})\n"
+ handler.current_turn = turn
+ gen = handler.dispatch(tool_name, args, response, index=ii, tool_num=len(tool_calls))
+ try:
+ v = next(gen)
+ def proxy(): yield v; return (yield from gen)
+ if verbose: yield '`````\n'
+ outcome = (yield from proxy()) if verbose else exhaust(proxy())
+ if verbose: yield '`````\n'
+ except StopIteration as e: outcome = e.value
+
+ if outcome.should_exit:
+ exit_reason = {'result': 'EXITED', 'data': outcome.data}; break
+ if not outcome.next_prompt:
+ exit_reason = {'result': 'CURRENT_TASK_DONE', 'data': outcome.data}; break
+ if outcome.next_prompt.startswith('未知工具'): client.last_tools = ''
+ if outcome.data is not None and tool_name != 'no_tool':
+ datastr = json.dumps(outcome.data, ensure_ascii=False, default=json_default) if type(outcome.data) in [dict, list] else str(outcome.data)
+ tool_results.append({'tool_use_id': tid, 'content': datastr})
+ next_prompts.add(outcome.next_prompt)
+ if len(next_prompts) == 0 or exit_reason:
+ if len(handler._done_hooks) == 0 or exit_reason.get('result', '') == 'EXITED': break
+ next_prompts.add(handler._done_hooks.pop(0))
+ next_prompt = handler.turn_end_callback(response, tool_calls, tool_results, turn, '\n'.join(next_prompts), exit_reason)
+ _hook('turn_after', locals())
+ messages = [{"role": "user", "content": next_prompt, "tool_results": tool_results}] # just new message, history is kept in *Session
+ if exit_reason: handler.turn_end_callback(response, tool_calls, tool_results, turn, '', exit_reason)
+ _hook('agent_after', locals())
+ return exit_reason or {'result': 'MAX_TURNS_EXCEEDED'}
- if outcome.next_prompt is None: return {'result': 'CURRENT_TASK_DONE', 'data': outcome.data}
- if outcome.should_exit: return {'result': 'EXITED', 'data': outcome.data}
- if outcome.next_prompt.startswith('未知工具'): client.last_tools = ''
+def _clean_content(text):
+ if not text: return ''
+ def _shrink_code(m):
+ lines = m.group(0).split('\n')
+ lang = lines[0].replace('```','').strip()
+ body = [l for l in lines[1:-1] if l.strip()]
+ if len(body) <= 6: return m.group(0)
+ preview = '\n'.join(body[:5])
+ return f'```{lang}\n{preview}\n ... ({len(body)} lines)\n```'
+ text = re.sub(r'```[\s\S]*?```', _shrink_code, text)
+ for p in [r'[\s\S]*? ', r'[\s\S]*? ', r'(\r?\n){3,}']:
+ text = re.sub(p, '\n\n' if '\\n' in p else '', text)
+ return text.strip()
- next_prompt = ""
- if outcome.data is not None:
- datastr = json.dumps(outcome.data, ensure_ascii=False, default=json_default) if type(outcome.data) in [dict, list] else str(outcome.data)
- next_prompt += f"\n{datastr}\n \n\n"
- next_prompt += outcome.next_prompt
- if (turn+1) % 7 == 0:
- next_prompt += f"\n\n[DANGER] 已连续执行第 {turn+1} 轮。禁止无效重试。若无有效进展,必须切换策略:1. 探测物理边界 2. 请求用户协助。"
- if (turn+1) % 30 == 0:
- next_prompt += f"\n\n### [DANGER] 已连续执行第 {turn+1} 轮。你必须总结情况进行ask_user,不允许继续重试。"
- messages = [{"role": "user", "content": next_prompt}]
- return {'result': 'MAX_TURNS_EXCEEDED'}
\ No newline at end of file
+def _compact_tool_args(name, args):
+ a = {k: v for k, v in args.items() if k != '_index'}
+ for k in ('path',):
+ if k in a: a[k] = os.path.basename(a[k])
+ if name == 'update_working_checkpoint': s = a.get('key_info', ''); return (s[:60]+'...') if len(s)>60 else s
+ if name == 'ask_user':
+ q = str(a.get('question', ''))
+ cs = a.get('candidates') or []
+ if cs: q += '\ncandidates:\n' + '\n'.join(f'- {c}' for c in cs)
+ return q
+ s = json.dumps(a, ensure_ascii=False); return (s[:120]+'...') if len(s)>120 else s
diff --git a/agentmain.py b/agentmain.py
index e3dd6bddb..be7514625 100644
--- a/agentmain.py
+++ b/agentmain.py
@@ -1,162 +1,315 @@
-import os, sys, threading, queue, time, json, re, random
+import os, sys, threading, queue, time, json, re, random, locale, glob
+os.environ.setdefault('GA_LANG', 'zh' if any(k in (locale.getlocale()[0] or '').lower() for k in ('zh', 'chinese')) else 'en')
if sys.stdout is None: sys.stdout = open(os.devnull, "w")
elif hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(errors='replace')
if sys.stderr is None: sys.stderr = open(os.devnull, "w")
elif hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(errors='replace')
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
-from sidercall import SiderLLMSession, LLMSession, ToolClient, ClaudeSession, XaiSession
-from agent_loop import agent_runner_loop, StepOutcome, BaseHandler
-from ga import GenericAgentHandler, smart_format, get_global_memory, format_error
+from llmcore import reload_mykeys, ToolClient, MixinSession, NativeToolClient, NativeClaudeSession, NativeOAISession, resolve_client
+from agent_loop import agent_runner_loop
+try:
+ from plugins.hooks import discover_and_load; discover_and_load()
+except Exception: pass
+from ga import GenericAgentHandler, smart_format, get_global_memory, format_error, consume_file
-with open('assets/tools_schema.json', 'r', encoding='utf-8') as f:
- TS = f.read()
+script_dir = os.path.dirname(os.path.abspath(__file__))
+BANNED_TOOLS = (['ask_user', 'start_long_term_update'] if '--no-user-tools' in sys.argv else [])
+def load_tool_schema(suffix=''):
+ global TOOLS_SCHEMA
+ TS = open(os.path.join(script_dir, f'assets/tools_schema{suffix}.json'), 'r', encoding='utf-8').read()
TOOLS_SCHEMA = json.loads(TS if os.name == 'nt' else TS.replace('powershell', 'bash'))
+ TOOLS_SCHEMA = [t for t in TOOLS_SCHEMA if t.get('function', {}).get('name') not in BANNED_TOOLS]
+load_tool_schema()
+
+lang_suffix = '_en' if os.environ.get('GA_LANG', '') == 'en' else ''
+mem_dir = os.path.join(script_dir, 'memory')
+if not os.path.exists(mem_dir): os.makedirs(mem_dir)
+mem_txt = os.path.join(mem_dir, 'global_mem.txt')
+if not os.path.exists(mem_txt): open(mem_txt, 'w', encoding='utf-8').write('# [Global Memory - L2]\n')
+mem_insight = os.path.join(mem_dir, 'global_mem_insight.txt')
+if not os.path.exists(mem_insight):
+ t = os.path.join(script_dir, f'assets/global_mem_insight_template{lang_suffix}.txt')
+ open(mem_insight, 'w', encoding='utf-8').write(open(t, encoding='utf-8').read() if os.path.exists(t) else '')
+cdp_cfg = os.path.join(script_dir, 'assets/tmwd_cdp_bridge/config.js')
+if not os.path.exists(cdp_cfg):
+ try:
+ os.makedirs(os.path.dirname(cdp_cfg), exist_ok=True)
+ open(cdp_cfg, 'w', encoding='utf-8').write(f"const TID = '__ljq_{hex(random.randint(0, 99999999))[2:8]}';")
+ except Exception as e: print(f'[WARN] CDP config init failed: {e} — advanced web features (tmwebdriver) will be unavailable.')
def get_system_prompt():
- if not os.path.exists('memory'): os.makedirs('memory')
- if not os.path.exists('memory/global_mem.txt'):
- with open('memory/global_mem.txt', 'w', encoding='utf-8') as f: f.write('')
- if not os.path.exists('memory/global_mem_insight.txt'):
- t = 'assets/global_mem_insight_template.txt'
- open('memory/global_mem_insight.txt', 'w', encoding='utf-8').write(open(t, encoding='utf-8').read() if os.path.exists(t) else '')
- with open('assets/sys_prompt.txt', 'r', encoding='utf-8') as f: prompt = f.read()
+ with open(os.path.join(script_dir, f'assets/sys_prompt{lang_suffix}.txt'), 'r', encoding='utf-8') as f: prompt = f.read()
prompt += f"\nToday: {time.strftime('%Y-%m-%d %a')}\n"
prompt += get_global_memory()
return prompt
-class GeneraticAgent:
+# SDK:
+# agent = GenericAgent(); threading.Thread(target=agent.run, daemon=True).start()
+# output1_queue = agent.put_task(prompt1)
+# output2_queue = agent.put_task(prompt2)
+class GenericAgent:
def __init__(self):
- if not os.path.exists('temp'): os.makedirs('temp')
- from sidercall import mykeys
+ os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True)
+ self.lock = threading.Lock()
+ self.task_dir = None
+ self.history = []; self.handler = None;
+ self.task_queue = queue.Queue()
+ self.is_running = False; self.stop_sig = False; self.llm_no = 0;
+ self.inc_out = False; self.verbose = True
+ self.peer_hint = True
+ self.force_non_stream = False
+ logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}'
+ self.log_path = os.path.join(script_dir, f'temp/model_responses/model_responses_{logid}.txt')
+ self.load_llm_sessions()
+ self.extra_sys_prompts = []
+ self.intervene = self.extrakeyinfo = None
+
+ def load_llm_sessions(self):
+ mykeys, changed = reload_mykeys()
+ if not changed and hasattr(self, 'llmclients'): return
+ try: oldhistory = self.llmclient.backend.history
+ except: oldhistory = None
llm_sessions = []
for k, cfg in mykeys.items():
if not any(x in k for x in ['api', 'config', 'cookie']): continue
try:
- if 'claude' in k: llm_sessions += [ClaudeSession(api_key=cfg['apikey'], api_base=cfg['apibase'], model=cfg['model'])]
- if 'oai' in k: llm_sessions += [LLMSession(api_key=cfg['apikey'], api_base=cfg['apibase'], model=cfg['model'], proxy=cfg.get('proxy'))]
- if 'xai' in k: llm_sessions += [XaiSession(cfg, mykeys.get('proxy', ''))]
- if 'sider' in k: llm_sessions += [SiderLLMSession(cfg, default_model=x) for x in \
- ["gemini-3.0-flash", "claude-haiku-4.5", "kimi-k2"]]
+ if 'mixin' in k: llm_sessions += [{'mixin_cfg': cfg}]
+ elif c := resolve_client(k): llm_sessions += [c]
except: pass
- if len(llm_sessions) > 0: self.llmclient = ToolClient(llm_sessions, auto_save_tokens=True)
- else: self.llmclient = None
- self.lock = threading.Lock()
- self.history = []
- self.task_queue = queue.Queue()
- self.is_running, self.stop_sig = False, False
- self.llm_no = 0
- self.inc_out = False
- self.handler = None
- self.verbose = True
-
+ for i, s in enumerate(llm_sessions):
+ if isinstance(s, dict) and 'mixin_cfg' in s:
+ try:
+ mixin = MixinSession(llm_sessions, s['mixin_cfg'])
+ if isinstance(mixin._sessions[0], (NativeClaudeSession, NativeOAISession)): llm_sessions[i] = NativeToolClient(mixin)
+ else: llm_sessions[i] = ToolClient(mixin)
+ except Exception as e: print(f'\n\n\n[ERROR] Failed to init MixinSession with cfg {s["mixin_cfg"]}: {e}!!!\n\n')
+ self.llmclients = llm_sessions
+ self.llmclient = self.llmclients[self.llm_no%len(self.llmclients)]
+ if oldhistory: self.llmclient.backend.history = oldhistory
+
def next_llm(self, n=-1):
- self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclient.backends)
+ self.load_llm_sessions()
+ self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclients)
+ lastc = self.llmclient
+ self.llmclient = self.llmclients[self.llm_no]
+ try: self.llmclient.backend.history = lastc.backend.history
+ except: raise Exception('[ERROR] BAD Mixin config: Check your mykey.py')
self.llmclient.last_tools = ''
- def list_llms(self): return [(i, f"{type(b).__name__}/{b.default_model}", i == self.llm_no) for i, b in enumerate(self.llmclient.backends)]
- def get_llm_name(self):
- b = self.llmclient.backends[self.llm_no]
- return f"{type(b).__name__}/{b.default_model}"
+ name = self.get_llm_name(model=True)
+ if 'glm' in name or 'minimax' in name or 'kimi' in name: load_tool_schema('_cn')
+ else: load_tool_schema()
+ def list_llms(self):
+ self.load_llm_sessions()
+ return [(i, self.get_llm_name(b), i == self.llm_no) for i, b in enumerate(self.llmclients)]
+ def get_llm_name(self, b=None, model=False):
+ b = self.llmclient if b is None else b
+ if isinstance(b, dict): return 'BADCONFIG_MIXIN'
+ if model: return b.backend.model.lower()
+ return f"{type(b.backend).__name__}/{b.backend.name}"
def abort(self):
- print('Abort current task...')
if not self.is_running: return
+ print('Abort current task...')
self.stop_sig = True
- if self.handler is not None:
- self.handler.code_stop_signal.append(1)
+ if self.handler is not None: self.handler.code_stop_signal.append(1)
- def put_task(self, query, source="user"):
+ def put_task(self, query, source="user", images=None):
display_queue = queue.Queue()
- self.task_queue.put({"query": query, "source": source, "output": display_queue})
+ self.task_queue.put({"query": query, "source": source, "images": images or [], "output": display_queue})
return display_queue
+ # i know it is dangerous, but raw_query is dangerous enough it doesn't enlarge
+ def _handle_slash_cmd(self, raw_query, display_queue):
+ if not raw_query.startswith('/'): return raw_query
+ if _sm := re.match(r'/session\.(\w+)=(.*)', raw_query.strip()):
+ k, v = _sm.group(1), _sm.group(2)
+ vfile = os.path.join(script_dir, 'temp', v)
+ if os.path.isfile(vfile): v = open(vfile, encoding='utf-8').read().strip()
+ try: v = json.loads(v) # cover number parsing
+ except (json.JSONDecodeError, ValueError): pass
+ setattr(self.llmclient.backend, k, v)
+ display_queue.put({'done': smart_format(f"✅ session.{k} = {repr(v)}", max_str_len=500), 'source': 'system'})
+ return None
+ if raw_query.strip() == '/resume':
+ return r'帮我看看最近有哪些会话可以恢复。读model_responses/目录,按修改时间取最近10个文件,从每个文件里找最后一个... 块,用一句话总结每个会话在聊什么,列表给我选。注意读文件后要把字面的\n替换成真换行才能正确匹配。'
+ return raw_query
+
def run(self):
while True:
task = self.task_queue.get()
- self.is_running = True
+ if isinstance(task, str): break
raw_query, source, display_queue = task["query"], task["source"], task["output"]
+ raw_query = self._handle_slash_cmd(raw_query, display_queue)
+ if raw_query is None:
+ self.task_queue.task_done(); continue
+ self.is_running = True
+ if len(raw_query) > 2000:
+ task_file = os.path.join(script_dir, 'temp', f'user_prompt_{int(time.time())}.md')
+ with open(task_file, 'w', encoding='utf-8') as f: f.write(raw_query)
+ raw_query = f'Long user prompt saved to {task_file}. Read and execute.'
rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200)
self.history.append(f"[USER]: {rquery}")
-
- sys_prompt = get_system_prompt()
- handler = GenericAgentHandler(None, self.history, './temp')
- if self.handler and self.handler.key_info:
- handler.key_info = self.handler.key_info
- if '清除工作记忆' not in handler.key_info:
- handler.key_info += '\n[SYSTEM] 如果是新任务,请先更新或清除工作记忆\n'
- self.handler = handler
- self.llmclient.backend = self.llmclient.backends[self.llm_no]
- gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query,
- handler, TOOLS_SCHEMA, max_turns=40, verbose=self.verbose)
+ sys_prompt = get_system_prompt() + '\n'.join(self.extra_sys_prompts) + getattr(self.llmclient.backend, 'extra_sys_prompt', '')
+ if self.peer_hint: sys_prompt += f"\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\n"
+ handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp'))
+ if getattr(self, 'no_print', False): handler.print = lambda *a, **k: None
+ if self.handler and 'key_info' in self.handler.working:
+ ki = re.sub(r'\n\[SYSTEM\] 此为.*?工作记忆[。\n]*', '', self.handler.working['key_info']) # 去旧
+ handler.working['key_info'] = ki
+ handler.working['passed_sessions'] = ps = self.handler.working.get('passed_sessions', 0) + 1
+ if ps > 0: handler.working['key_info'] += f'\n[SYSTEM] 此为 {ps} 个对话前设置的key_info,若已在新任务,先更新或清除工作记忆。\n'
+ self.handler = handler # although new handler, the **full** history is in llmclient, so it is full history!
+ self.llmclient.log_path = self.log_path
+ if self.force_non_stream:
+ self.llmclient.backend.stream = False
+ self.llmclient.backend.read_timeout = max(self.llmclient.backend.read_timeout, 1200)
+ gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query, handler, TOOLS_SCHEMA,
+ max_turns=180, verbose=self.verbose, yield_info=True)
try:
- full_resp = ""; last_pos = 0
+ full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = []
for chunk in gen:
+ if consume_file(self.task_dir, '_stop'): self.abort()
if self.stop_sig: break
- full_resp += chunk
- if len(full_resp) - last_pos > 50:
- display_queue.put({'next': full_resp[last_pos:] if self.inc_out else full_resp, 'source': source})
+ if isinstance(chunk, dict) and 'turn' in chunk:
+ curr_turn = chunk['turn']; turn_resps.append(''); continue
+ full_resp += chunk; turn_resps[-1] += chunk
+ if len(full_resp) - last_pos > 30 or 'LLM Running' in chunk:
+ display_queue.put({'next': full_resp[last_pos:] if self.inc_out else full_resp,
+ 'source': source, 'turn': curr_turn, 'outputs': turn_resps[-2:]})
last_pos = len(full_resp)
- if self.inc_out and last_pos < len(full_resp): display_queue.put({'next': full_resp[last_pos:], 'source': source})
- if '' in full_resp: full_resp = full_resp.replace('', '\n\n')
- if '' in full_resp: full_resp = re.sub(r'\s*(.*?)\s* ', r'\n````\n\n\1\n \n````', full_resp, flags=re.DOTALL)
- display_queue.put({'done': full_resp, 'source': source})
+ if self.inc_out and last_pos < len(full_resp):
+ display_queue.put({'next': full_resp[last_pos:], 'source': source,
+ 'turn': curr_turn, 'outputs': turn_resps[-2:]})
+ display_queue.put({'done': full_resp, 'source': source, 'turn': curr_turn, 'outputs': turn_resps.copy()})
self.history = handler.history_info
except Exception as e:
print(f"Backend Error: {format_error(e)}")
- display_queue.put({'done': full_resp + f'\n```\n{format_error(e)}\n```', 'source': source})
+ display_queue.put({'done': full_resp + f'\n```\n{format_error(e)}\n```', 'source': source, 'turn': curr_turn, 'outputs': turn_resps.copy()})
finally:
+ if self.stop_sig: print('User aborted the task.')
self.is_running = self.stop_sig = False
self.task_queue.task_done()
if self.handler is not None: self.handler.code_stop_signal.append(1)
-
+GeneraticAgent = GenericAgent
+
if __name__ == '__main__':
import argparse
from datetime import datetime
parser = argparse.ArgumentParser()
- parser.add_argument('--scheduled', action='store_true', help='计划任务轮询模式')
- parser.add_argument('--task', metavar='IODIR', help='一次性任务模式(文件IO)')
- parser.add_argument('--llm_no', type=int, default=0, help='LLM编号')
- args = parser.parse_args()
+ parser.add_argument('--task', metavar='IODIR', help='一次性任务模式,先看subagent.md')
+ parser.add_argument('--func', metavar='PROMPT_FILE', help='纯函数模式:读prompt文件→结果写prompt.out.txt→退出')
+ parser.add_argument('--reflect', metavar='SCRIPT', help='反射模式:加载监控脚本,check()触发时发任务')
+ parser.add_argument('--input', help='prompt')
+ parser.add_argument('--history', help='history json file')
+ parser.add_argument('--llm_no', type=int, default=0)
+ parser.add_argument('--verbose', action='store_true')
+ parser.add_argument('--nobg', action='store_true')
+ parser.add_argument('--nolog', action='store_true')
+ parser.add_argument('--no-user-tools', action='store_true')
+ args, _unknown = parser.parse_known_args()
+ _extra_args = dict(zip([k.lstrip('-') for k in _unknown[::2]], _unknown[1::2])) if _unknown else {}
+
+ if (args.func or args.task) and not args.nobg:
+ import subprocess, platform
+ cmd = [sys.executable, os.path.abspath(__file__)] + [a for a in sys.argv[1:]] + ['--nobg']
+ if args.task:
+ d = os.path.join(script_dir, f'temp/{args.task}'); os.makedirs(d, exist_ok=True)
+ out = open(os.path.join(d, 'stdout.log'), 'w', encoding='utf-8')
+ err = open(os.path.join(d, 'stderr.log'), 'w', encoding='utf-8')
+ else: out, err = subprocess.DEVNULL, subprocess.DEVNULL
+ p = subprocess.Popen(cmd, cwd=script_dir,
+ creationflags=0x08000000 if platform.system() == 'Windows' else 0,
+ stdout=out, stderr=err)
+ print('PID:', p.pid); sys.exit(0)
- agent = GeneraticAgent()
- agent.llm_no = args.llm_no
- agent.verbose = False
+ agent = GenericAgent()
+ if args.nolog: agent.log_path = False
+ agent.next_llm(args.llm_no)
+ agent.verbose = args.verbose
threading.Thread(target=agent.run, daemon=True).start()
+ histfile = args.history
if args.task:
- d = f'temp/{args.task}'; rp = f'{d}/reply.txt'; nround = ''
- with open(f'{d}/input.txt', encoding='utf-8') as f: raw = f.read()
+ agent.task_dir = d = os.path.join(script_dir, f'temp/{args.task}'); nround = ''
+ infile = os.path.join(d, 'input.txt'); outfile = f'{d}/output{nround}.txt'
+ if args.input:
+ os.makedirs(d, exist_ok=True)
+ [os.remove(f) for f in glob.glob(os.path.join(d, 'output*.txt'))]
+ with open(infile, 'w', encoding='utf-8') as f: f.write(args.input)
+ histfile = histfile or os.path.join(d, '_history.json')
+ elif args.func:
+ infile = args.func; outfile = os.path.splitext(args.func)[0] + '.out.txt'
+
+ if histfile and os.path.isfile(histfile): agent.llmclient.backend.history = json.loads(open(histfile, encoding='utf-8').read())
+
+ if args.func or args.task:
+ agent.peer_hint = False
+ with open(infile, encoding='utf-8') as f: raw = f.read()
while True:
- dq = agent.put_task(raw, source='task')
- while 'done' not in (item := dq.get(timeout=120)):
- if 'next' in item and random.random() < 0.05: # 1/20的概率写一次中间结果
- with open(f'{d}/output{nround}.txt', 'w', encoding='utf-8') as f: f.write(item.get('next', ''))
- with open(f'{d}/output{nround}.txt', 'w', encoding='utf-8') as f: f.write(item['done'] + '\n[ROUND END]\n')
- for _ in range(150): # 等reply.txt,5分钟超时
+ dq = agent.put_task(raw, source='func' if args.func else 'task')
+ while 'done' not in (item := dq.get(timeout=2200)):
+ if 'next' in item:
+ with open(outfile, 'w', encoding='utf-8') as f: f.write(item.get('next', ''))
+ with open(outfile, 'w', encoding='utf-8') as f: f.write(item['done'] + '\n\n[ROUND END]\n')
+ if not args.task: break
+ consume_file(d, '_stop') # 已经成功停下来了,避免打断下次reply
+ for _ in range(300): # 等reply.txt,10分钟超时
time.sleep(2)
- if os.path.exists(rp):
- with open(rp, encoding='utf-8') as f: raw = f.read()
- os.remove(rp); break
+ if (raw := consume_file(d, 'reply.txt')): break
else: break
- nround = int(nround) + 1 if nround.isdigit() else 1
- elif args.scheduled:
- def drain(dq, tag):
- while 'done' not in (item := dq.get()): pass
- open('./temp/scheduler.log', 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}] {tag}\n{item["done"]}\n\n')
+ nround = nround + 1 if isinstance(nround, int) else 1
+ outfile = f'{d}/output{nround}.txt'
+ elif args.reflect:
+ agent.peer_hint = False
+ import importlib.util
+ spec = importlib.util.spec_from_file_location('reflect_script', args.reflect)
+ mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
+ if hasattr(mod, 'init'): mod.init(_extra_args)
+ _mt = os.path.getmtime(args.reflect)
+ print(f'[Reflect] loaded {args.reflect}' + (f' args={_extra_args}' if _extra_args else ''))
while True:
- time.sleep(55 + random.random() * 10)
- now = datetime.now()
- if not os.path.isdir('./sche_tasks/pending'): continue
- for f in os.listdir('./sche_tasks/pending'):
- m = re.match(r'(\d{4}-\d{2}-\d{2})_(\d{4})_', f)
- if m and now >= datetime.strptime(f'{m[1]} {m[2]}', '%Y-%m-%d %H%M'):
- raw = open(f'./sche_tasks/pending/{f}', encoding='utf-8').read()
- dq = agent.put_task(f'按scheduled_task_sop执行任务文件 ../sche_tasks/pending/{f}(立刻移到running)\n内容:\n{raw}', source='scheduler')
- threading.Thread(target=drain, args=(dq, f), daemon=True).start()
- break
+ if os.path.getmtime(args.reflect) != _mt:
+ try:
+ spec.loader.exec_module(mod); _mt = os.path.getmtime(args.reflect)
+ if hasattr(mod, 'init'): mod.init(_extra_args)
+ print('[Reflect] reloaded')
+ except Exception as e: print(f'[Reflect] reload error: {e}')
+ try: task = mod.check()
+ except Exception as e:
+ print(f'[Reflect] check() error: {e}'); task = None
+ if task and task == '/exit': break
+ if task:
+ print(f'[Reflect] triggered: {task[:80]}')
+ dq = agent.put_task(task, source='reflect')
+ try:
+ while 'done' not in (item := dq.get(timeout=2200)): pass
+ result = item['done']
+ print(result)
+ except Exception as e:
+ if getattr(mod, 'ONCE', False): raise
+ print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}'
+ log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True)
+ script_name = os.path.splitext(os.path.basename(args.reflect))[0]
+ open(os.path.join(log_dir, f'{script_name}_{datetime.now():%Y-%m-%d}.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\n{result}\n\n')
+ if (on_done := getattr(mod, 'on_done', None)):
+ try: on_done(result)
+ except Exception as e: print(f'[Reflect] on_done error: {e}')
+ if getattr(mod, 'ONCE', False): print('[Reflect] ONCE=True, exiting.'); break
+ time.sleep(getattr(mod, 'INTERVAL', 5))
else:
+ try: import readline
+ except Exception: pass
agent.inc_out = True
+ if sys.stdout.isatty():
+ try: model = agent.get_llm_name(model=True) or '?'
+ except Exception: model = '?'
+ try:
+ sys.stdout.write(f'\x1b[92m✦\x1b[0m \x1b[1mGenericAgent\x1b[0m '
+ f'\x1b[90m· cli · model:\x1b[0m {model}\n')
+ sys.stdout.flush()
+ except Exception: pass
while True:
q = input('> ').strip()
if not q: continue
@@ -167,5 +320,4 @@ def drain(dq, tag):
if 'next' in item: print(item['next'], end='', flush=True)
if 'done' in item: print(); break
except KeyboardInterrupt:
- agent.abort()
- print('\n[Interrupted]')
\ No newline at end of file
+ agent.abort(); print('\n[Interrupted]')
diff --git a/assets/GenericAgent_Technical_Report.pdf b/assets/GenericAgent_Technical_Report.pdf
new file mode 100644
index 000000000..cd5d59be6
Binary files /dev/null and b/assets/GenericAgent_Technical_Report.pdf differ
diff --git a/assets/agent_bbs.py b/assets/agent_bbs.py
new file mode 100644
index 000000000..7c8dd3548
--- /dev/null
+++ b/assets/agent_bbs.py
@@ -0,0 +1,225 @@
+# agent_bbs.py — 极简Agent公告板(多板块版)
+# 启动: uvicorn agent_bbs:app --host 0.0.0.0 --port 58800
+# 或: python agent_bbs.py
+
+import sqlite3, uuid, time, json, os
+from threading import Lock, Thread
+from fastapi import FastAPI, HTTPException, Query, Body, UploadFile, File
+from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse, FileResponse
+from contextlib import contextmanager
+from starlette.requests import Request
+from starlette.responses import Response
+from starlette.middleware.base import BaseHTTPMiddleware
+
+# key → board config; 修改 boards.json 可热重载新增板块
+BOARDS_FILE = "boards.json"
+DEFAULT_BOARDS = {"agent-bbs-test": {"name": "default", "db": "agent_bbs.db"}}
+BOARDS, BOARDS_MTIME_NS, BOARDS_LOCK = DEFAULT_BOARDS, None, Lock()
+_T=[time.time()]
+
+def load_boards_if_changed():
+ global BOARDS, BOARDS_MTIME_NS
+ with BOARDS_LOCK:
+ if BOARDS_FILE is None:
+ if BOARDS_MTIME_NS is None: init_db(); BOARDS_MTIME_NS = 0
+ return BOARDS
+ if not os.path.exists(BOARDS_FILE):
+ json.dump(DEFAULT_BOARDS, open(BOARDS_FILE, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
+ mtime = os.stat(BOARDS_FILE).st_mtime_ns
+ if mtime == BOARDS_MTIME_NS: return BOARDS
+ try:
+ new = json.load(open(BOARDS_FILE, "r", encoding="utf-8"))
+ assert isinstance(new, dict) and all(isinstance(v, dict) and "db" in v and "name" in v for v in new.values())
+ BOARDS, BOARDS_MTIME_NS = new, mtime; init_db()
+ print(f"[boards] reloaded {len(BOARDS)} boards")
+ except Exception as e: print(f"[boards] reload failed, keep old config: {e}")
+ return BOARDS
+
+UPLOAD_DIR = "bbs_files"
+
+app = FastAPI(title="Agent BBS", docs_url=None, redoc_url=None, openapi_url=None)
+
+class ApiKeyMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next):
+ key = request.headers.get("x-api-key") or request.query_params.get("key")
+ board = load_boards_if_changed().get(key)
+ if not board: return Response("Not Found", status_code=404)
+ request.state.board = board
+ return await call_next(request)
+
+app.add_middleware(ApiKeyMiddleware)
+
+HTML_PAGE = """
+Agent BBS
+
+Agent BBS
+
+ All Agents
+ Refresh
+ ◀ Prev Next ▶
+
+
+
+"""
+
+README_TEXT = "Agent BBS API\tAuth: ALL requests require header X-API-Key: or pass ?key= as query parameter.\t1. Register: POST /register body: {\"name\": \"your-agent-name\"}\tResponse: {\"token\": \"xxx\", \"name\": \"your-agent-name\"}\t2. Post: POST /post body: {\"token\": \"xxx\", \"content\": \"your message\"}\tResponse: {\"id\": 1, \"author\": \"your-agent-name\"}\t3. Poll new: GET /poll?since_id=0&limit=50\tReturns posts with id > since_id, ordered by id asc. Keep track of the last id you received, use it as since_id next time.\t4. Query: GET /posts?author=xxx&limit=50\tauthor is optional. Returns posts ordered by id desc. 5. Upload file: POST /file/upload multipart/form-data, form fields: token (your agent token) + file (the file). Requires X-API-Key. Response: {\"ref\": \"a1b2c3/filename.ext\"}. Paste ref into post content to reference the file. 6. Download file: GET /file/{rand_id}/{filename} Requires X-API-Key. e.g. /file/a1b2c3/filename.ext"
+
+@app.get("/readme")
+def readme(): return PlainTextResponse(README_TEXT)
+
+@app.get("/", response_class=HTMLResponse)
+def index(): return HTML_PAGE
+
+@contextmanager
+def get_db(db_path):
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ try:
+ yield conn
+ conn.commit()
+ finally: conn.close()
+
+def _db(request): return request.state.board["db"]
+
+def init_db():
+ for board in BOARDS.values():
+ with get_db(board["db"]) as db:
+ db.execute("""CREATE TABLE IF NOT EXISTS users (
+ token TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, created_at REAL)""")
+ db.execute("""CREATE TABLE IF NOT EXISTS posts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL,
+ content TEXT NOT NULL, created_at REAL,
+ FOREIGN KEY(author) REFERENCES users(name))""")
+ db.execute("CREATE INDEX IF NOT EXISTS idx_posts_id ON posts(id)")
+
+def verify_token(token, db_path):
+ with get_db(db_path) as db:
+ row = db.execute("SELECT name FROM users WHERE token=?", (token,)).fetchone()
+ if not row: raise HTTPException(401, "invalid token")
+ return row["name"]
+
+@app.on_event("startup")
+def startup():
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
+ load_boards_if_changed()
+
+@app.post("/register")
+def register(request: Request, name=Body(..., embed=True)):
+ token = uuid.uuid4().hex[:16]
+ try:
+ with get_db(_db(request)) as db:
+ db.execute("INSERT INTO users VALUES(?,?,?)", (token, name, time.time()))
+ except sqlite3.IntegrityError:
+ with get_db(_db(request)) as db:
+ row = db.execute("SELECT token FROM users WHERE name=?", (name,)).fetchone()
+ return {"token": row["token"], "name": name}
+ return {"token": token, "name": name}
+
+@app.post("/post")
+def create_post(request: Request, token=Body(...), content=Body(...)):
+ author = verify_token(token, _db(request))
+ with get_db(_db(request)) as db:
+ cur = db.execute("INSERT INTO posts(author,content,created_at) VALUES(?,?,?)",
+ (author, content, time.time()))
+ post_id = cur.lastrowid
+ _T[0]=time.time()
+ return {"id": post_id, "author": author}
+
+@app.get("/poll")
+def poll(request: Request, since_id=Query(0), limit=Query(50)):
+ with get_db(_db(request)) as db:
+ rows = db.execute("SELECT id,author,content,created_at FROM posts WHERE id>? ORDER BY id LIMIT ?",
+ (since_id, limit)).fetchall()
+ return [dict(r) for r in rows]
+
+@app.get("/count")
+def count_posts(request: Request, author=Query(None)):
+ with get_db(_db(request)) as db:
+ q, p = ("SELECT COUNT(*) c FROM posts WHERE author=?", (author,)) if author else ("SELECT COUNT(*) c FROM posts", ())
+ return {"total": db.execute(q, p).fetchone()["c"]}
+
+@app.get("/authors")
+def get_authors(request: Request):
+ with get_db(_db(request)) as db:
+ return [r["author"] for r in db.execute("SELECT DISTINCT author FROM posts ORDER BY author").fetchall()]
+
+@app.get("/posts")
+def get_posts(request: Request, author=Query(None), limit=Query(50), offset=Query(0)):
+ with get_db(_db(request)) as db:
+ if author:
+ rows = db.execute("SELECT id,author,content,created_at FROM posts WHERE author=? ORDER BY id DESC LIMIT ? OFFSET ?",
+ (author, limit, offset)).fetchall()
+ else:
+ rows = db.execute("SELECT id,author,content,created_at FROM posts ORDER BY id DESC LIMIT ? OFFSET ?",
+ (limit, offset)).fetchall()
+ return [dict(r) for r in rows]
+
+@app.post("/file/upload")
+def upload_file(request: Request, token=Body(...), file: UploadFile = File(...)):
+ verify_token(token, _db(request))
+ rand_id = uuid.uuid4().hex[:6]
+ safe_name = os.path.basename(file.filename)
+ dest = os.path.join(UPLOAD_DIR, rand_id)
+ os.makedirs(dest, exist_ok=True)
+ with open(os.path.join(dest, safe_name), "wb") as f:
+ f.write(file.file.read())
+ return {"ref": f"{rand_id}/{safe_name}"}
+
+@app.get("/file/{rand_id}/{filename}")
+def download_file(rand_id: str, filename: str):
+ path = os.path.join(UPLOAD_DIR, rand_id, os.path.basename(filename))
+ if not os.path.exists(path):
+ raise HTTPException(404, "not found")
+ return FileResponse(path, filename=filename)
+
+if __name__ == "__main__":
+ import argparse, uvicorn
+ p = argparse.ArgumentParser(); p.add_argument("--cwd"); p.add_argument("--port", type=int, default=58800); p.add_argument("--key")
+ a = p.parse_args();
+ if a.cwd: os.chdir(a.cwd)
+ if a.key: BOARDS_FILE = None; BOARDS.clear(); BOARDS[a.key] = {"name": "default", "db": f"{a.key}.db"}; Thread(target=lambda:[time.sleep(3600) or time.time()-_T[0]>172800 and os._exit(0) for _ in iter(int,1)],daemon=True).start()
+ uvicorn.run(app, host="0.0.0.0", port=a.port)
\ No newline at end of file
diff --git a/assets/code_run_header.py b/assets/code_run_header.py
new file mode 100644
index 000000000..59fb3416b
--- /dev/null
+++ b/assets/code_run_header.py
@@ -0,0 +1,27 @@
+import sys, os, json, re, time, subprocess
+sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'memory'))
+_r = subprocess.run
+def _d(b):
+ if not b: return ''
+ if isinstance(b, str): return b
+ try: return b.decode()
+ except: return b.decode('gbk', 'replace')
+def _run(*a, **k):
+ t = k.pop('text', 0) | k.pop('universal_newlines', 0)
+ enc = k.pop('encoding', None)
+ k.pop('errors', None)
+ if enc: t = 1
+ if t and isinstance(k.get('input'), str):
+ k['input'] = k['input'].encode()
+ r = _r(*a, **k)
+ if t:
+ if r.stdout is not None: r.stdout = _d(r.stdout)
+ if r.stderr is not None: r.stderr = _d(r.stderr)
+ return r
+subprocess.run = _run
+_Pi = subprocess.Popen.__init__
+def _pinit(self, *a, **k):
+ if os.name == 'nt': k['creationflags'] = (k.get('creationflags') or 0) | 0x08000000
+ _Pi(self, *a, **k)
+subprocess.Popen.__init__ = _pinit
+sys.excepthook = lambda t, v, tb: (sys.__excepthook__(t, v, tb), print(f"\n[Agent Hint]: NO GUESSING! You MUST probe first. If missing common package, pip.")) if issubclass(t, (ImportError, AttributeError)) else sys.__excepthook__(t, v, tb)
diff --git a/assets/configure_mykey.py b/assets/configure_mykey.py
new file mode 100644
index 000000000..de685e6cc
--- /dev/null
+++ b/assets/configure_mykey.py
@@ -0,0 +1,1432 @@
+#!/usr/bin/env python3
+"""
+GenericAgent — 交互式初始化向导 (configure.py)
+一键配置 LLM 模型 + 消息平台,自动生成 mykey.py
+
+用法:
+ python configure.py
+"""
+
+import ast
+import os
+import sys
+import re
+import shutil
+import json
+import urllib.request
+from datetime import datetime
+
+# ── ANSI 颜色 ──────────────────────────────────────────────────────────────
+C = {
+ 'reset': '\033[0m', 'bold': '\033[1m', 'dim': '\033[2m',
+ 'red': '\033[91m', 'green': '\033[92m', 'yellow': '\033[93m',
+ 'blue': '\033[94m', 'magenta': '\033[95m', 'cyan': '\033[96m', 'white': '\033[97m',
+}
+
+PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+MYKPY_PATH = os.path.join(PROJECT_ROOT, 'mykey.py')
+
+# ── 模型厂商定义 ───────────────────────────────────────────────────────────
+
+LLM_PROVIDERS = [
+ # ═══════════════════════════ 通用协议(官方直连或任意兼容中转)═══════════════════════════
+ {
+ 'id': 'oai_chat',
+ 'name': 'OpenAI Chat Completions 协议',
+ 'desc': '官方直连或任意 OAI 兼容中转/网关,自填 apibase(回车=OpenAI 官方)',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'gpt-native', 'apikey': 'sk-',
+ 'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.5',
+ 'api_mode': 'chat_completions', 'reasoning_effort': 'high',
+ 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120,
+ },
+ 'key_hint': '官方在 https://platform.openai.com/api-keys 获取;中转站填其提供的 Key',
+ 'model_choices': ['gpt-5.5', 'gpt-5.4'],
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.openai.com/v1'},
+ ],
+ },
+ {
+ 'id': 'oai_responses',
+ 'name': 'OpenAI Responses 协议',
+ 'desc': 'Responses API(o 系列/GPT-5.5 推荐端点),官方或兼容网关,自填 apibase',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'gpt-responses', 'apikey': 'sk-',
+ 'apibase': 'https://api.openai.com/v1', 'model': 'gpt-5.5',
+ 'api_mode': 'responses', 'reasoning_effort': 'high',
+ 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120,
+ },
+ 'key_hint': '官方在 https://platform.openai.com/api-keys 获取;中转站填其提供的 Key',
+ 'model_choices': ['gpt-5.5', 'gpt-5.4'],
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.openai.com/v1'},
+ ],
+ },
+ {
+ 'id': 'claude_messages',
+ 'name': 'Claude Messages 协议',
+ 'desc': 'Anthropic 官方直连或任意 Claude 兼容中转,自填 apibase(回车=官方)',
+ 'type': 'native_claude',
+ 'template': {
+ 'name': 'anthropic-direct', 'apikey': 'sk-ant-',
+ 'apibase': 'https://api.anthropic.com', 'model': 'claude-opus-4-7',
+ 'thinking_type': 'adaptive', 'max_tokens': 32768, 'temperature': 1,
+ },
+ 'key_hint': '官方在 https://console.anthropic.com/ 获取;中转站填其提供的 Key',
+ 'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'],
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API Base(官方或中转地址)', 'default': 'https://api.anthropic.com'},
+ ],
+ },
+ # ═══════════════════════════ 直连 API(按旗舰能力降序)═══════════════════════════
+ {
+ 'id': 'deepseek',
+ 'name': 'DeepSeek (v4-Pro / Flash)',
+ 'desc': '开源模型,v4-Pro 旗舰 1M 上下文',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'deepseek', 'apikey': 'sk-',
+ 'apibase': 'https://api.deepseek.com', 'model': 'deepseek-v4-pro',
+ 'api_mode': 'chat_completions', 'reasoning_effort': 'high',
+ 'context_win': 1000000, 'max_tokens': 384000,
+ },
+ 'key_hint': '在 https://platform.deepseek.com/api_keys 获取',
+ 'model_choices': ['deepseek-v4-pro', 'deepseek-v4-flash'],
+ },
+ {
+ 'id': 'kimi',
+ 'name': 'Kimi (k2.6 / k2.5) 双协议',
+ 'desc': '月之暗面,支持 Anthropic 和 OAI 双协议',
+ 'type': 'native_claude',
+ 'template': {
+ 'name': 'kimi', 'apikey': 'sk-kimi-',
+ 'apibase': 'https://api.kimi.com/coding',
+ 'model': 'kimi-for-coding', 'fake_cc_system_prompt': True,
+ 'thinking_type': 'adaptive',
+ },
+ 'key_hint': '在 https://kimi.com/code 或 https://platform.moonshot.cn/ 获取',
+ 'model_choices': ['kimi-k2.6', 'kimi-k2.5'],
+ 'extra_fields': [
+ {
+ 'key': '_protocol', 'label': '选择 API 协议',
+ 'type': 'choice',
+ 'options': [
+ {'id': 'native_claude', 'name': 'Anthropic 兼容 (推荐)', 'desc': 'kimi-for-coding 端点,CC 兼容', 'apibase': 'https://api.kimi.com/coding', 'fake_cc_system_prompt': True, 'model': 'kimi-for-coding'},
+ {'id': 'native_oai', 'name': 'OpenAI 协议', 'desc': 'Moonshot OAI 端点,kimi-k2 系列', 'apibase': 'https://api.moonshot.cn/v1', 'model': 'kimi-k2.6'},
+ ],
+ },
+ ],
+ },
+ {
+ 'id': 'qwen',
+ 'name': '阿里通义千问 (Qwen3.5 / 百炼)',
+ 'desc': '阿里云百炼,Qwen3 系列百万级上下文',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'qwen', 'apikey': 'sk-',
+ 'apibase': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
+ 'model': 'qwen3.6-max-preview',
+ 'api_mode': 'chat_completions',
+ },
+ 'key_hint': '在 https://bailian.console.aliyun.com/ 获取 API Key',
+ 'model_choices': ['qwen3.6-max-preview', 'qwen3.5-plus', 'qwen3-coder-plus'],
+ 'extra_fields': [
+ {
+ 'key': '_endpoint', 'label': '选择端点',
+ 'type': 'choice',
+ 'options': [
+ {'id': 'standard', 'name': '标准按量付费', 'desc': 'dashscope.aliyuncs.com,兼容模式', 'apibase': 'https://dashscope.aliyuncs.com/compatible-mode/v1'},
+ {'id': 'coding_plan', 'name': '百炼 Coding Plan (订阅)', 'desc': 'coding-intl.dashscope.aliyuncs.com,100万上下文', 'apibase': 'https://coding-intl.dashscope.aliyuncs.com/v1', 'context_win': 1000000},
+ ],
+ },
+ ],
+ },
+ {
+ 'id': 'zhipu',
+ 'name': '智谱 GLM-5.1 (Coding Plan)',
+ 'desc': '智谱 GLM,支持 Coding Plan CN (Anthropic) 和 Global (OAI) 双端点',
+ 'type': 'native_claude',
+ 'template': {
+ 'name': 'zhipu-glm', 'apikey': 'sk-',
+ 'apibase': 'https://open.bigmodel.cn/api/anthropic',
+ 'model': 'GLM-5.1-Cloud', 'fake_cc_system_prompt': False,
+ 'thinking_type': 'adaptive', 'max_retries': 3,
+ 'connect_timeout': 10, 'read_timeout': 180,
+ },
+ 'key_hint': 'CN 在 https://open.bigmodel.cn/ 获取;Global 在 https://z.ai/ 获取',
+ 'model_choices': ['GLM-5.1-Cloud', 'glm-4.7'],
+ 'extra_fields': [
+ {
+ 'key': '_plan', 'label': '选择 Coding Plan',
+ 'type': 'choice',
+ 'options': [
+ {'id': 'native_claude', 'name': 'Coding Plan CN (Anthropic)', 'desc': 'open.bigmodel.cn,推荐国内用户', 'apibase': 'https://open.bigmodel.cn/api/anthropic', 'fake_cc_system_prompt': False},
+ {'id': 'native_oai', 'name': 'Coding Plan Global (OAI)', 'desc': 'api.z.ai,OpenAI 协议,全球可用', 'apibase': 'https://api.z.ai/api/paas/v4'},
+ ],
+ },
+ ],
+ },
+ {
+ 'id': 'minimax',
+ 'name': 'MiniMax M3 (双协议)',
+ 'desc': 'MiniMax M3,支持 Anthropic 和 OpenAI 双协议',
+ 'type': 'native_claude',
+ 'template': {
+ 'name': 'minimax', 'apikey': 'eyJh...',
+ 'apibase': 'https://api.minimaxi.com/anthropic',
+ 'model': 'MiniMax-M3', 'max_retries': 3,
+ },
+ 'key_hint': '在 https://platform.minimaxi.com/user-center/basic-information 获取',
+ 'model_choices': ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'],
+ 'extra_fields': [
+ {
+ 'key': '_protocol', 'label': '选择 API 协议',
+ 'type': 'choice',
+ 'options': [
+ {'id': 'native_claude', 'name': 'Anthropic 协议 (推荐)', 'desc': '无 标签,原生 Claude 兼容', 'apibase': 'https://api.minimaxi.com/anthropic'},
+ {'id': 'native_oai', 'name': 'OpenAI 协议', 'desc': '走 /v1/chat/completions', 'apibase': 'https://api.minimaxi.com/v1', 'context_win': 50000},
+ ],
+ },
+ ],
+ },
+ {
+ 'id': 'stepfun',
+ 'name': '阶跃星辰 Step-3.5 (推理强)',
+ 'desc': '阶跃星辰 Step 系列,支持标准和 Step Plan 双端点',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'stepfun', 'apikey': 'sk-',
+ 'apibase': 'https://api.stepfun.com/v1',
+ 'model': 'step-3.5-flash',
+ 'api_mode': 'chat_completions',
+ 'context_win': 262144,
+ },
+ 'key_hint': '在 https://platform.stepfun.com/ 获取 API Key',
+ 'model_choices': ['step-3.5-flash', 'step-3.5-flash-2603'],
+ 'extra_fields': [
+ {
+ 'key': '_endpoint', 'label': '选择端点',
+ 'type': 'choice',
+ 'options': [
+ {'id': 'standard', 'name': '标准端点', 'desc': 'api.stepfun.com/v1,按量付费', 'apibase': 'https://api.stepfun.com/v1', 'context_win': 262144},
+ {'id': 'step_plan', 'name': 'Step Plan (订阅)', 'desc': 'api.stepfun.com/step_plan/v1,订阅制', 'apibase': 'https://api.stepfun.com/step_plan/v1', 'context_win': 262144},
+ ],
+ },
+ ],
+ },
+ {
+ 'id': 'qianfan',
+ 'name': '百度千帆 (ERNIE 5.0 / 第三方)',
+ 'desc': '百度智能云千帆,文心一言 ERNIE 5.0 + DeepSeek 等',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'baidu-qianfan', 'apikey': '',
+ 'apibase': 'https://qianfan.baidubce.com/v2',
+ 'model': 'ernie-5.0-thinking-preview',
+ 'api_mode': 'chat_completions',
+ },
+ 'key_hint': '在 https://console.bce.baidu.com/qianfan/ 创建应用获取 API Key',
+ 'model_choices': ['ernie-5.0-thinking-preview', 'deepseek-v3.2'],
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://qianfan.baidubce.com/v2'},
+ ],
+ },
+ {
+ 'id': 'volcengine',
+ 'name': '火山引擎 (豆包 / Ark)',
+ 'desc': '字节跳动火山引擎,支持标准 Ark 和 Ark Coding Plan',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'volc-ark', 'apikey': '',
+ 'apibase': 'https://ark.cn-beijing.volces.com/api/v3',
+ 'model': 'doubao-seed-code-preview-251028',
+ 'api_mode': 'chat_completions',
+ },
+ 'key_hint': '在 https://console.volcengine.com/ark/ 创建推理接入点后获取 API Key',
+ 'model_choices': ['doubao-seed-code-preview-251028', 'doubao-seed-1-8-251228'],
+ 'extra_fields': [
+ {
+ 'key': '_endpoint', 'label': '选择端点',
+ 'type': 'choice',
+ 'options': [
+ {'id': 'standard', 'name': '标准 Ark', 'desc': 'ark.cn-beijing.volces.com/api/v3,按量付费', 'apibase': 'https://ark.cn-beijing.volces.com/api/v3'},
+ {'id': 'coding_plan', 'name': 'Ark Coding Plan (订阅)', 'desc': 'ark.cn-beijing.volces.com/api/coding/v3', 'apibase': 'https://ark.cn-beijing.volces.com/api/coding/v3'},
+ ],
+ },
+ ],
+ },
+ {
+ 'id': 'xiaomi',
+ 'name': '小米 MiMo (MiMo 2.5 Pro / TokenPlan)',
+ 'desc': '小米 MiMo 系列,超大上下文窗口,支持 TokenPlan 预付费',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'xiaomi-mimo', 'apikey': 'sk-',
+ 'apibase': 'https://api.xiaomimimo.com/v1',
+ 'model': 'mimo-v2.5-pro',
+ 'api_mode': 'chat_completions',
+ 'context_win': 1000000, 'max_tokens': 128000,
+ },
+ 'key_hint': '在 https://x.xiaomi.com/ 获取 API Key',
+ 'model_choices': ['mimo-v2.5-pro', 'mimo-v2-flash'],
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://api.xiaomimimo.com/v1'},
+ ],
+ },
+ {
+ 'id': 'tencent_tokenhub',
+ 'name': '腾讯混元 TokenHub (Hy3 / TokenPlan)',
+ 'desc': '腾讯云 TokenHub,混元 Hy3 系列,TokenPlan 预付费',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'tencent-tokenhub', 'apikey': 'sk-',
+ 'apibase': 'https://tokenhub.tencentmaas.com/v1',
+ 'model': 'hy3-preview',
+ 'api_mode': 'chat_completions',
+ },
+ 'key_hint': '在 https://console.cloud.tencent.com/tokenhub 获取 API Key',
+ 'model_choices': ['hy3-preview'],
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://tokenhub.tencentmaas.com/v1'},
+ ],
+ },
+ # ═══════════════════════════ 代理 / 中继(支持 Claude/GPT 等顶级模型)══════════
+ {
+ 'id': 'cc_relay',
+ 'name': 'CC Switch 透传 (社区常用)',
+ 'desc': '社区 Claude Code 透传渠道,可接入 Claude Opus',
+ 'type': 'native_claude',
+ 'template': {
+ 'name': 'cc-relay', 'apikey': 'sk-user-',
+ 'apibase': 'https:///claude/office',
+ 'model': 'claude-opus-4-7', 'fake_cc_system_prompt': True,
+ 'thinking_type': 'adaptive',
+ },
+ 'key_hint': '从你的 CC Switch 服务商获取 apikey 和 apibase',
+ 'model_choices': ['claude-opus-4-7', 'claude-sonnet-4-6'],
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://your-host/claude/office'},
+ {'key': 'fake_cc_system_prompt', 'label': 'fake_cc_system_prompt', 'type': 'bool', 'default': True},
+ ],
+ },
+ {
+ 'id': 'openrouter',
+ 'name': 'OpenRouter (多模型中继)',
+ 'desc': '一个 Key 通吃 Claude/GPT/DeepSeek/Qwen 等',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'openrouter', 'apikey': 'sk-or-',
+ 'apibase': 'https://openrouter.ai/api/v1',
+ 'model': 'anthropic/claude-opus-4-7',
+ 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120,
+ },
+ 'key_hint': '在 https://openrouter.ai/keys 获取',
+ 'model_choices': ['anthropic/claude-opus-4-7', 'openai/gpt-5.5'],
+ },
+ {
+ 'id': 'commonstack',
+ 'name': 'CommonStack (统一网关)',
+ 'desc': '一个 Key 通吃 Claude/GPT/Gemini/DeepSeek/MiniMax/Zhipu/xAI 等',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'commonstack', 'apikey': 'sk-',
+ 'apibase': 'https://api.commonstack.ai/v1',
+ 'model': 'anthropic/claude-opus-4-7',
+ 'api_mode': 'chat_completions',
+ 'max_retries': 3, 'connect_timeout': 10, 'read_timeout': 120,
+ },
+ 'key_hint': '在 https://commonstack.ai 注册后从 Dashboard 获取 API Key',
+ 'model_choices': ['anthropic/claude-opus-4-7', 'openai/gpt-5.5'],
+ },
+ {
+ 'id': 'crs',
+ 'name': 'CRS 反代 (Claude Max 多通道)',
+ 'desc': 'CRS 协议的反代服务,支持 Claude Max / Gemini Ultra 通道',
+ 'type': 'native_claude',
+ 'template': {
+ 'name': 'crs', 'apikey': 'cr_',
+ 'apibase': 'https:///api',
+ 'model': 'claude-opus-4-7[1m]', 'fake_cc_system_prompt': True,
+ 'thinking_type': 'adaptive', 'max_tokens': 32768,
+ 'max_retries': 3, 'read_timeout': 180,
+ },
+ 'key_hint': '从你的 CRS 服务商获取 key 和 host',
+ 'model_choices': ['claude-opus-4-7[1m]', 'claude-sonnet-4-6'],
+ 'extra_fields': [
+ {
+ 'key': '_channel', 'label': '选择 CRS 通道',
+ 'type': 'choice',
+ 'options': [
+ {'id': 'claude_max', 'name': 'Claude Max (默认)', 'desc': '标准 CRS Claude 通道', 'apibase': 'https:///api'},
+ {'id': 'gemini_ultra', 'name': 'Gemini Ultra (Antigravity)', 'desc': 'CRS 包装的 Google Antigravity,不支持 SSE 流式', 'apibase': 'https:///antigravity/api', 'model': 'claude-opus-4-7-thinking', 'stream': False},
+ ],
+ },
+ ],
+ },
+ {
+ 'id': 'gmi',
+ 'name': 'GMI Serving (通用模型中继)',
+ 'desc': 'GMI 通用模型推理服务,支持多种开源/闭源(手动输入模型名)',
+ 'type': 'native_oai',
+ 'template': {
+ 'name': 'gmi', 'apikey': '',
+ 'apibase': 'https://api.gmi-serving.com/v1',
+ 'model': 'gmi-default',
+ 'api_mode': 'chat_completions',
+ },
+ 'key_hint': '从 GMI 服务商获取 API Key,探测失败时手动输入模型名',
+ 'model_choices': [], # 中继服务,模型由服务商提供,探测失败时手动输入
+ 'extra_fields': [
+ {'key': 'apibase', 'label': 'API 地址 (apibase)', 'default': 'https://api.gmi-serving.com/v1'},
+ ],
+ },
+]
+
+# ── 消息平台定义 ────────────────────────────────────────────────────────────
+PLATFORMS = [
+ {
+ 'id': 'none',
+ 'name': '不使用消息平台(纯终端 REPL)',
+ 'desc': '直接用 python agentmain.py 在终端交互',
+ 'deps': [],
+ },
+ {
+ 'id': 'telegram',
+ 'name': 'Telegram 机器人',
+ 'desc': '通过 Telegram Bot 与 Agent 对话',
+ 'file': 'frontends/tgapp.py',
+ 'deps': ['python-telegram-bot'],
+ 'env_vars': [
+ {'key': 'tg_bot_token', 'label': 'Bot Token', 'hint': '从 @BotFather 获取'},
+ {'key': 'tg_allowed_users', 'label': '允许的用户 ID(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True},
+ ],
+ },
+ {
+ 'id': 'qq',
+ 'name': 'QQ 机器人',
+ 'desc': '通过 QQ 官方机器人 API 接入',
+ 'file': 'frontends/qqapp.py',
+ 'deps': ['qq-botpy'],
+ 'env_vars': [
+ {'key': 'qq_app_id', 'label': 'App ID', 'hint': 'QQ 开放平台获取'},
+ {'key': 'qq_app_secret', 'label': 'App Secret'},
+ {'key': 'qq_allowed_users', 'label': '允许的用户 OpenID(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True},
+ ],
+ },
+ {
+ 'id': 'feishu',
+ 'name': '飞书机器人',
+ 'desc': '通过飞书应用与 Agent 对话',
+ 'file': 'frontends/fsapp.py',
+ 'deps': ['lark-oapi'],
+ 'env_vars': [
+ {'key': 'fs_app_id', 'label': 'App ID', 'hint': '飞书开放平台获取'},
+ {'key': 'fs_app_secret', 'label': 'App Secret'},
+ {'key': 'fs_allowed_users', 'label': '允许的用户(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True},
+ ],
+ },
+ {
+ 'id': 'wecom',
+ 'name': '企业微信机器人',
+ 'desc': '通过企业微信 Bot 接入',
+ 'file': 'frontends/wecomapp.py',
+ 'deps': ['wecombot'],
+ 'env_vars': [
+ {'key': 'wecom_bot_id', 'label': 'Bot ID'},
+ {'key': 'wecom_secret', 'label': 'Bot Secret'},
+ {'key': 'wecom_allowed_users', 'label': '允许的用户(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True},
+ ],
+ },
+ {
+ 'id': 'dingtalk',
+ 'name': '钉钉机器人',
+ 'desc': '通过钉钉应用接入',
+ 'file': 'frontends/dingtalkapp.py',
+ 'deps': ['dingtalk-sdk'],
+ 'env_vars': [
+ {'key': 'dingtalk_client_id', 'label': 'Client ID (App Key)'},
+ {'key': 'dingtalk_client_secret', 'label': 'Client Secret (App Secret)'},
+ {'key': 'dingtalk_allowed_users', 'label': '允许的用户 StaffID(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True},
+ ],
+ },
+ {
+ 'id': 'discord',
+ 'name': 'Discord 机器人',
+ 'desc': '通过 Discord Bot 接入',
+ 'file': 'frontends/dcapp.py',
+ 'deps': ['discord.py'],
+ 'env_vars': [
+ {'key': 'dc_bot_token', 'label': 'Bot Token', 'hint': 'Discord Developer Portal 获取'},
+ {'key': 'dc_allowed_users', 'label': '允许的用户 ID(逗号分隔, 留空=所有人)', 'default': '[]', 'is_list': True},
+ ],
+ },
+ {
+ 'id': 'wechat',
+ 'name': '微信 (iLink 协议)',
+ 'desc': '通过微信个人号与 Agent 对话,扫码自动登录',
+ 'file': 'frontends/wechatapp.py',
+ 'deps': ['requests', 'qrcode', 'pycryptodome'],
+ 'env_vars': [],
+ },
+]
+
+
+def _masked(v, reveal, tail):
+ """生成脱敏字符串:前 reveal 位明文 + * + 后 tail 位明文"""
+ if len(v) > reveal + tail:
+ return v[:reveal] + '*' * min(len(v) - reveal - tail, 8) + v[-tail:]
+ elif len(v) > reveal:
+ return v[:reveal] + '*' * (len(v) - reveal)
+ return v
+
+def masked_input(prompt, reveal=6, tail=4):
+ """密文输入,支持粘贴:批读取 + 延迟重绘,避免快速键入时丢字符。
+
+ prompt 必须为单行(不含 \\n)。
+ """
+ sys.stdout.write(prompt)
+ sys.stdout.flush()
+ chars = []
+
+ def _repaint():
+ m = _masked(''.join(chars), reveal, tail)
+ sys.stdout.write(f'\r{prompt}{m} \r{prompt}{m}')
+ sys.stdout.flush()
+
+ def _process(c):
+ """处理单个字符,返回 True 表示应退出。"""
+ if c in ('\r', '\n'):
+ return True
+ if c in ('\x03', '\x04'):
+ raise KeyboardInterrupt
+ if c in ('\x08', '\x7f'):
+ if chars:
+ chars.pop()
+ elif c.isprintable() or c == ' ':
+ chars.append(c)
+ return False
+
+ if os.name == 'nt':
+ import msvcrt
+ while True:
+ c = msvcrt.getwch()
+ if _process(c):
+ break
+ if c in ('\x08', '\x7f'):
+ _repaint() # 退格立即重绘
+ continue
+ if not (c.isprintable() or c == ' '):
+ continue
+ # 批量读取:粘贴时一次取完
+ while msvcrt.kbhit():
+ c2 = msvcrt.getwch()
+ if _process(c2):
+ value = ''.join(chars)
+ _repaint()
+ sys.stdout.write('\n')
+ sys.stdout.flush()
+ return value
+ _repaint()
+ else:
+ import tty, termios, select
+ fd = sys.stdin.fileno()
+ old = termios.tcgetattr(fd)
+ try:
+ tty.setraw(fd)
+ while True:
+ c = sys.stdin.read(1)
+ if _process(c):
+ break
+ if c in ('\x08', '\x7f'):
+ _repaint() # 退格立即重绘
+ continue
+ if not (c.isprintable() or c == ' '):
+ continue
+ # 批量读取:只要 stdin 有数据就继续读,不重绘
+ while select.select([sys.stdin], [], [], 0) == ([sys.stdin], [], []):
+ c2 = sys.stdin.read(1)
+ if _process(c2):
+ value = ''.join(chars)
+ _repaint()
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
+ sys.stdout.write('\n')
+ sys.stdout.flush()
+ return value
+ _repaint()
+ finally:
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
+
+ value = ''.join(chars)
+ _repaint()
+ sys.stdout.write('\n')
+ sys.stdout.flush()
+ return value
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# UI Helpers
+# ═══════════════════════════════════════════════════════════════════════════
+
+def cprint(text, color=None, bold=False, end='\n'):
+ parts = []
+ if color: parts.append(C.get(color, ''))
+ if bold: parts.append(C['bold'])
+ parts.append(text)
+ parts.append(C['reset'])
+ print(''.join(parts), end=end)
+
+def banner():
+ print('\033[2J\033[H', end='') # ANSI 清屏,跨平台
+ print(f"{C['cyan']}{C['bold']}")
+ print(" ╔═══════════════════════════════════════════════════════════╗")
+ print(" ║ GenericAgent — 交互式初始化向导 v1.2 ║")
+ print(" ║ 一键配置 LLM 模型 + 消息平台,自动生成 mykey.py ║")
+ print(" ╚═══════════════════════════════════════════════════════════╝")
+ print(f"{C['reset']}")
+ print(f"{C['dim']} 项目目录: {PROJECT_ROOT}{C['reset']}")
+ print()
+
+def _check_python():
+ """检查 Python 版本,返回 (ok, msg)"""
+ vi = sys.version_info
+ if vi < (3, 10):
+ return False, f"Python {vi.major}.{vi.minor} 不满足最低要求 (≥ 3.10)"
+ if vi[:2] == (3, 12):
+ return True, ''
+ return True, f"⚠ 当前 Python {vi.major}.{vi.minor},推荐使用 Python 3.12"
+
+def ask_choice(prompt, choices, allow_multi=False, default=None):
+ """交互式选择,返回 selected_id 或 [selected_ids]"""
+ print(f"\n{C['bold']}{prompt}{C['reset']}")
+ if allow_multi:
+ print(f"{C['dim']} (可多选,输入序号用逗号分隔,如: 1,3,5;输入 a 全选;回车跳过){C['reset']}")
+ else:
+ print(f"{C['dim']} (输入序号,如: 1){C['reset']}")
+ for i, c in enumerate(choices, 1):
+ desc = c.get('desc', '')
+ print(f" {C['green']}{i}.{C['reset']} {C['bold']}{c['name']}{C['reset']} {C['dim']}{desc}{C['reset']}")
+ while True:
+ raw = input(f"\n {C['yellow']}►{C['reset']} ").strip()
+ if not raw and default is not None:
+ return default
+ if allow_multi:
+ if raw.lower() == 'a':
+ return [c['id'] for c in choices]
+ parts = [p.strip() for p in raw.split(',') if p.strip()]
+ selected = []
+ for p in parts:
+ try:
+ idx = int(p) - 1
+ if 0 <= idx < len(choices):
+ selected.append(choices[idx]['id'])
+ except ValueError:
+ pass
+ if selected:
+ return selected
+ else:
+ try:
+ idx = int(raw) - 1
+ if 0 <= idx < len(choices):
+ return choices[idx]['id']
+ except ValueError:
+ pass
+ print(f" {C['red']}✗ 请输入有效序号{C['reset']}")
+
+def ask_input(prompt, default=None, secret=False, hint=None):
+ """交互式输入。secret=True 时使用脱敏输入。"""
+ if hint:
+ cprint(f" {hint}", 'dim')
+ if default is not None:
+ cprint(f" [默认: {default}]", 'dim')
+ prompt_line = f" {C['yellow']}►{C['reset']} {prompt}: "
+ while True:
+ if secret:
+ val = masked_input(prompt_line).strip()
+ else:
+ val = input(prompt_line).strip()
+ if not val and default is not None:
+ return default
+ if val:
+ return val
+ cprint("✗ 此项不能为空", 'red')
+
+def ask_yesno(prompt, default=True):
+ hint = "Y/N"
+ raw = input(f"\n {C['yellow']}►{C['reset']} {prompt} ({hint}): ").strip().lower()
+ if not raw:
+ return default
+ return raw.startswith('y')
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# LLM 配置逻辑
+# ═══════════════════════════════════════════════════════════════════════════
+
+def _get_proxy_handler():
+ """从环境变量读取代理配置,返回 ProxyHandler 或 None"""
+ for var in ('HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'):
+ url = os.environ.get(var)
+ if url:
+ return urllib.request.ProxyHandler({'https': url, 'http': url})
+ return None
+
+def probe_models(provider, apikey, apibase=None):
+ """调用 API 探测可用模型列表,返回模型 ID 列表或 None"""
+ ptype = provider.get('type', 'native_oai')
+ base = (apibase or provider['template'].get('apibase', '')).rstrip('/')
+
+ if ptype == 'native_claude':
+ url = f"{base}/v1/models"
+ headers = {'x-api-key': apikey, 'anthropic-version': '2023-06-01', 'User-Agent': 'GenericAgent/1.0'}
+ else:
+ url = f"{base}/models"
+ headers = {'Authorization': f'Bearer {apikey}', 'User-Agent': 'GenericAgent/1.0'}
+
+ print(f"\n {C['dim']}🔍 正在探测可用模型 ({base}/models)...{C['reset']}", end='', flush=True)
+ if ptype == 'native_claude':
+ print(f" {C['dim']}(Anthropic 协议,探测可能失败){C['reset']}", end='', flush=True)
+
+ opener = urllib.request.build_opener()
+ ph = _get_proxy_handler()
+ if ph:
+ opener = urllib.request.build_opener(ph)
+ print(f" {C['dim']}(via proxy){C['reset']}", end='', flush=True)
+
+ for attempt in range(2):
+ try:
+ req = urllib.request.Request(url, headers=headers, method='GET')
+ with opener.open(req, timeout=10) as resp:
+ data = json.loads(resp.read().decode())
+ models = data.get('data', [])
+ ids = sorted(set(m['id'] for m in models if isinstance(m, dict) and m.get('id')))
+ if ids:
+ print(f" {C['green']}✓ 发现 {len(ids)} 个模型{C['reset']}")
+ return ids
+ print(f" {C['yellow']}⚠ 返回为空{C['reset']}")
+ return None
+ except Exception as e:
+ if attempt == 0 and 'timeout' in type(e).__name__.lower():
+ print(f" {C['yellow']}⏱ 超时,重试...{C['reset']}", end='', flush=True)
+ continue
+ print(f" {C['yellow']}⚠ 探测失败: {type(e).__name__}(将使用预设列表){C['reset']}")
+ return None
+ return None
+
+def _normalize_model_choices(choices):
+ """统一 model_choices 格式为 [{'id': str, 'name': str}]"""
+ if not choices:
+ return []
+ result = []
+ for item in choices:
+ if isinstance(item, str):
+ result.append({'id': item, 'name': item})
+ elif isinstance(item, dict):
+ result.append(item)
+ elif isinstance(item, (tuple, list)) and len(item) >= 1:
+ result.append({'id': item[0], 'name': item[1] if len(item) > 1 else item[0]})
+ return result
+
+def _configure_advanced(provider, cfg):
+ """配置高级可选字段: proxy, context_win, stream, user_agent, thinking_budget_tokens"""
+ print(f"\n {C['dim']}── 高级选项(回车跳过,使用默认值){C['reset']}")
+ proxy = ask_input("HTTP 代理地址 (proxy)", default='', hint='如 http://127.0.0.1:2082,留空跳过')
+ if proxy:
+ cfg['proxy'] = proxy
+ cw = ask_input("上下文窗口阈值 (context_win)", default='', hint='NativeClaude 默认 28000,其他默认 24000')
+ if cw:
+ cfg['context_win'] = int(cw)
+ if cfg.get('thinking_type') == 'enabled':
+ tbt = ask_input("thinking_budget_tokens", default='', hint='low≈4096, medium≈10240, high≈32768')
+ if tbt:
+ cfg['thinking_budget_tokens'] = int(tbt)
+ if cfg.get('type', provider['type']) == 'native_claude':
+ ua = ask_input("User-Agent 版本号", default='', hint='某些中转按 UA 白名单校验,pin 老版本用')
+ if ua:
+ cfg['user_agent'] = ua
+ stream_default = cfg.get('stream', True)
+ if ask_yesno("启用 SSE 流式 (stream)", default=stream_default):
+ cfg['stream'] = True
+ else:
+ cfg['stream'] = False
+
+def configure_llm(provider):
+ """引导用户配置单个模型"""
+ print(f"\n{C['cyan']}{'─'*60}{C['reset']}")
+ print(f"{C['bold']} 配置: {provider['name']}{C['reset']}")
+ print(f" {C['dim']}{provider['desc']}{C['reset']}")
+ print(f"{C['cyan']}{'─'*60}{C['reset']}")
+
+ cfg = dict(provider['template'])
+
+ # API Key(密文输入)
+ cfg['apikey'] = ask_input(
+ f"API Key",
+ hint=provider.get('key_hint', ''),
+ secret=True,
+ )
+
+ # 额外字段
+ for field in provider.get('extra_fields', []):
+ if field['key'] == 'apibase':
+ cfg['apibase'] = ask_input(
+ field['label'],
+ default=field.get('default', cfg.get('apibase', '')),
+ )
+ elif field.get('type') == 'bool':
+ cfg[field['key']] = ask_yesno(
+ field['label'],
+ default=field.get('default', True)
+ )
+ elif field.get('type') == 'choice':
+ picked = ask_choice(field['label'], field['options'])
+ chosen = next(o for o in field['options'] if o['id'] == picked)
+ for opt_key, opt_val in chosen.items():
+ if opt_key not in ('id', 'name', 'desc'):
+ cfg[opt_key] = opt_val
+
+ # 模型选择
+ manual_choice = {'id': '__manual__', 'name': '✏️ 手动输入模型名', 'desc': '自定义模型 ID,不依赖探测结果'}
+ model_list = probe_models(provider, cfg['apikey'], cfg.get('apibase'))
+ if model_list:
+ refresh_choice = {'id': '__refresh__', 'name': '🔄 重新探测'}
+ choices = [refresh_choice, manual_choice] + [{'id': m, 'name': m} for m in model_list]
+ while True:
+ picked = ask_choice("API 探测到以下可用模型(或手动输入):", choices)
+ if picked == '__refresh__':
+ print(f" {C['dim']}再次探测...{C['reset']}")
+ model_list = probe_models(provider, cfg['apikey'], cfg.get('apibase'))
+ if not model_list:
+ print(f" {C['yellow']}⚠ 再次探测失败{C['reset']}")
+ picked = _fallback_model(provider, manual_choice)
+ break
+ choices = [refresh_choice, manual_choice] + [{'id': m, 'name': m} for m in model_list]
+ elif picked == '__manual__':
+ picked = ask_input("请输入模型名", default=cfg.get('model', ''))
+ break
+ else:
+ break
+ cfg['model'] = picked
+ else:
+ cfg['model'] = _fallback_model(provider, manual_choice)
+
+ # 别名
+ default_name = cfg.get('name', provider['id'])
+ name = ask_input("此配置的别名 (name,Mixin 引用用)", default=default_name)
+ if name:
+ cfg['name'] = name
+
+ # 高级选项
+ if ask_yesno("配置高级选项(proxy / context_win / stream 等)?", default=False):
+ _configure_advanced(provider, cfg)
+
+ return cfg
+
+def _fallback_model(provider, manual_choice=None):
+ """使用预设模型列表让用户选择,始终提供手动输入选项"""
+ manual_choice = manual_choice or {'id': '__manual__', 'name': '✏️ 手动输入模型名', 'desc': '自定义模型 ID'}
+ normalized = _normalize_model_choices(provider.get('model_choices', []))
+ if normalized:
+ choices = [manual_choice] + normalized
+ picked = ask_choice("选择模型(或手动输入):", choices)
+ if picked == '__manual__':
+ return ask_input("请输入模型名", default=provider['template'].get('model', ''))
+ return picked
+ return ask_input("请输入模型名", default=provider['template'].get('model', ''))
+
+def configure_llms():
+ """配置 LLM 模型"""
+ print(f"\n{C['bold']}{C['magenta']}╔══════════════════════════════════════╗")
+ print(f"║ 第一步: 配置 LLM 模型 ║")
+ print(f"╚══════════════════════════════════════╝{C['reset']}")
+ print(f"\n{C['dim']} 你可以配置最多 2 个模型组成故障转移 (Mixin) 列表。{C['reset']}")
+
+ all_cfgs = []
+ provider_id = ask_choice("选择模型厂商 (配置第 1 个模型):", LLM_PROVIDERS)
+ provider = next(p for p in LLM_PROVIDERS if p['id'] == provider_id)
+ cfg = configure_llm(provider)
+ all_cfgs.append(cfg)
+
+ if ask_yesno("再添加一个模型做故障转移?", default=False):
+ providers_ext = [{'id': '__stop__', 'name': '✓ 不需要备选了', 'desc': ''}] + LLM_PROVIDERS
+ provider_id = ask_choice(
+ "选择模型厂商 (配置第 2 个模型 — 或选「不需要备选了」跳过):",
+ providers_ext
+ )
+ if provider_id != '__stop__':
+ provider = next(p for p in LLM_PROVIDERS if p['id'] == provider_id)
+ cfg = configure_llm(provider)
+ all_cfgs.append(cfg)
+
+ return all_cfgs
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 消息平台配置逻辑
+# ═══════════════════════════════════════════════════════════════════════════
+
+def configure_platforms():
+ """配置消息平台,返回 (platform_configs, pip_hints)"""
+ print(f"\n{C['bold']}{C['magenta']}╔══════════════════════════════════════╗")
+ print(f"║ 第二步: 配置消息平台 ║")
+ print(f"╚══════════════════════════════════════╝{C['reset']}")
+ print(f"\n{C['dim']} 消息平台用于从聊天软件与 Agent 交互。{C['reset']}")
+ print(f"{C['dim']} 你也可以跳过此步,直接用终端 REPL。{C['reset']}")
+
+ platform_ids = ask_choice(
+ "选择消息平台 (可多选,选 '不使用' 则跳过):",
+ PLATFORMS,
+ allow_multi=True,
+ default=['none']
+ )
+
+ if 'none' in platform_ids:
+ return [], set()
+
+ selected_platforms = []
+ pip_hints = set()
+
+ for pid in platform_ids:
+ platform = next(p for p in PLATFORMS if p['id'] == pid)
+ pip_hints.update(platform.get('deps', []))
+
+ print(f"\n{C['cyan']}{'─'*60}{C['reset']}")
+ print(f"{C['bold']} 配置: {platform['name']}{C['reset']}")
+ print(f"{C['cyan']}{'─'*60}{C['reset']}")
+
+ env_vals = {}
+
+ if pid == 'feishu' and ask_yesno("使用一键扫码创建应用?(推荐)", default=True):
+ env_vals = _feishu_scan(platform)
+ if pid == 'wechat' and ask_yesno("扫码登录微信 iLink?(推荐)", default=True):
+ env_vals = _wechat_scan()
+
+ for var in platform['env_vars']:
+ if var['key'] not in env_vals:
+ env_vals.update(_manual_platform_var(var))
+
+ if pid == 'wecom' and ask_yesno("设置欢迎消息?", default=False):
+ env_vals['wecom_welcome_message'] = ask_input("欢迎消息内容", default='你好,我在线上。')
+
+ selected_platforms.append({'platform': platform, 'config': env_vals})
+
+ return selected_platforms, pip_hints
+
+def _manual_platform_var(var):
+ """手动填写单个平台变量"""
+ val = ask_input(var['label'], hint=var.get('hint', ''), default=var.get('default'))
+ if var.get('is_list'):
+ if val == '[]' or not val:
+ return {var['key']: []}
+ return {var['key']: [x.strip() for x in val.split(',') if x.strip()]}
+ return {var['key']: val}
+
+def _feishu_scan(platform):
+ """飞书一键扫码创建应用,返回 env_vals 或空 dict"""
+ from io import StringIO
+ try:
+ import lark_oapi as lark
+ import qrcode, threading
+ except ImportError:
+ print(f"\n {C['yellow']}⚠ lark-oapi 未安装,降级为手动配置{C['reset']}")
+ return {}
+
+ print(f"\n {C['cyan']}📱 正在启动一键创建...{C['reset']}")
+ print(f" {C['dim']} 请用飞书 App 扫描终端二维码,完成授权后自动获取凭据。{C['reset']}\n")
+
+ qr_printed = threading.Event()
+ result_holder = {'data': None}
+
+ def handle_qr(info):
+ url = info['url']
+ expire = info['expire_in']
+ qr = qrcode.QRCode(border=1, box_size=1)
+ qr.add_data(url)
+ buf = StringIO()
+ qr.print_ascii(out=buf)
+ qr_art = buf.getvalue()
+ print(f"\n {C['bold']}请用飞书扫描下方二维码,或复制链接在浏览器打开:{C['reset']}")
+ print(f" {C['green']}{qr_art.replace(chr(27), '')}{C['reset']}")
+ print(f" {C['dim']} 链接: {url}{C['reset']}")
+ print(f" {C['dim']} 有效期 {expire} 秒{C['reset']}")
+ qr_printed.set()
+
+ def handle_status(info):
+ status = info['status']
+ if status == 'polling':
+ print(f" {C['yellow']}⏳ 等待扫码...{C['reset']}")
+ elif status == 'slow_down':
+ print(f" {C['yellow']}⏳ 等待中... (间隔 {info.get('interval', '?')}s){C['reset']}")
+ elif status == 'domain_switched':
+ print(f" {C['cyan']}🌐 已切换认证域名{C['reset']}")
+
+ def run_register():
+ try:
+ result = lark.register_app(
+ on_qr_code=handle_qr,
+ on_status_change=handle_status,
+ )
+ result_holder['data'] = result
+ except Exception as e:
+ print(f"\n {C['red']}✗ 创建失败: {e}{C['reset']}")
+
+ thread = threading.Thread(target=run_register, daemon=True)
+ thread.start()
+ qr_printed.wait(timeout=15)
+ thread.join(timeout=300)
+
+ if result_holder['data']:
+ result = result_holder['data']
+ print(f"\n {C['green']}✅ 应用创建成功!{C['reset']}")
+ print(f" App ID: {C['bold']}{result['client_id']}{C['reset']}")
+ print(f" App Secret: {C['bold']}{result['client_secret']}{C['reset']}")
+ return {
+ 'fs_app_id': result['client_id'],
+ 'fs_app_secret': result['client_secret'],
+ }
+ else:
+ print(f"\n {C['yellow']}⚠ 扫码创建未完成,降级为手动填写...{C['reset']}")
+ return {}
+
+
+def _wechat_scan():
+ """微信 iLink 扫码登录,保存 token 到 ~/.wxbot/token.json,返回 env_vals"""
+ print(f"\n {C['cyan']}📱 正在启动微信 iLink 扫码登录...{C['reset']}")
+ print(f" {C['dim']} 请用微信扫描终端二维码,完成授权后自动获取凭据。{C['reset']}\n")
+
+ # 确保项目根在路径中,以便导入 frontends/wechatapp
+ if PROJECT_ROOT not in sys.path:
+ sys.path.insert(0, PROJECT_ROOT)
+ try:
+ from frontends.wechatapp import WxBotClient
+ except ImportError as e:
+ print(f"\n {C['yellow']}⚠ 无法导入 WxBotClient: {e}{C['reset']}")
+ return {}
+
+ try:
+ bot = WxBotClient()
+ if bot.token:
+ print(f" {C['green']}✅ 已有有效 token (bot_id={bot.bot_id}){C['reset']}")
+ if ask_yesno("重新扫码登录?", default=False):
+ bot.token = ''
+ else:
+ return {}
+ bot.login_qr()
+ print(f"\n {C['green']}✅ 微信 iLink 扫码登录成功!{C['reset']}")
+ print(f" Bot ID: {C['bold']}{bot.bot_id}{C['reset']}")
+ print(f" Token 已保存到: {C['dim']}{bot._tf}{C['reset']}")
+ except Exception as e:
+ print(f"\n {C['red']}✗ 扫码登录失败: {e}{C['reset']}")
+ return {}
+
+ return {}
+
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 生成 mykey.py
+# ═══════════════════════════════════════════════════════════════════════════
+
+def _var_type_info(cfg):
+ """根据配置类型返回 (var_prefix, session_type)"""
+ cfg_type = cfg.get('type', 'native_oai')
+ if cfg_type == 'native_claude':
+ return 'native_claude_config', 'NativeClaudeSession'
+ elif cfg_type == 'claude':
+ return 'claude_config', 'ClaudeSession'
+ elif cfg_type == 'oai':
+ return 'oai_config', 'LLMSession'
+ else:
+ return 'native_oai_config', 'NativeOAISession'
+
+
+def generate_mykey(llm_cfgs, platform_configs):
+ """生成 mykey.py 内容"""
+ lines = []
+ lines.append("# ══════════════════════════════════════════════════════════════════════════════")
+ lines.append(f"# GenericAgent — mykey.py (由 configure.py 自动生成 @ {datetime.now().strftime('%Y-%m-%d %H:%M')})")
+ lines.append("# ══════════════════════════════════════════════════════════════════════════════")
+ lines.append("")
+ lines.append("# ── 停止符 ──────────────────────────────────────────────────────────────────")
+ lines.append("_SETUP_DONE = 'configure.py' # 删除此行可重新触发配置向导")
+ lines.append("")
+
+ # Mixin 配置
+ names = [c['name'] for c in llm_cfgs]
+ lines.append("# ── Mixin 故障转移 ──────────────────────────────────────────────────────────")
+ lines.append("mixin_config = {")
+ lines.append(f" 'llm_nos': {names},")
+ lines.append(" 'max_retries': 10,")
+ lines.append(" 'base_delay': 0.5,")
+ lines.append("}")
+ lines.append("")
+
+ # 各模型配置
+ type_counts = {}
+ for cfg in llm_cfgs:
+ cfg_type = cfg.get('type', 'native_oai')
+ type_counts[cfg_type] = type_counts.get(cfg_type, 0) + 1
+
+ type_indices = {}
+ for i, cfg in enumerate(llm_cfgs):
+ cfg_type = cfg.get('type', 'native_oai')
+ var_prefix, session_type = _var_type_info(cfg)
+ idx = type_indices.get(cfg_type, 0)
+ type_indices[cfg_type] = idx + 1
+
+ if type_counts[cfg_type] > 1:
+ var_name = f"{var_prefix}_{idx}"
+ else:
+ var_name = var_prefix
+
+ lines.append(f"# ── {cfg['name']} ({session_type}) ─────────────────────────────────────────────")
+ lines.append(f"{var_name} = {{")
+ _write_config_fields(lines, cfg)
+ lines.append("}")
+ lines.append("")
+
+ # 平台配置
+ if platform_configs:
+ lines.append("# ══════════════════════════════════════════════════════════════════════════════")
+ lines.append("# 聊天平台集成")
+ lines.append("# ══════════════════════════════════════════════════════════════════════════════")
+ lines.append("")
+ for pc in platform_configs:
+ for key, val in pc['config'].items():
+ _write_platform_value(lines, key, val)
+ lines.append("")
+
+ # 尾部
+ lines.append("# ══════════════════════════════════════════════════════════════════════════════")
+ lines.append("# 配置完毕!运行: python agentmain.py (终端 REPL)")
+ if platform_configs:
+ for pc in platform_configs:
+ p = pc['platform']
+ lines.append(f"# 或: python {p['file']} ({p['name']})")
+ lines.append("# ══════════════════════════════════════════════════════════════════════════════")
+
+ return '\n'.join(lines)
+
+def _write_config_fields(lines, cfg):
+ """写入配置字典的键值对(缩进的 'key': value, 格式)"""
+ for key in ['name', 'type', 'apikey', 'apibase', 'model', 'api_mode',
+ 'fake_cc_system_prompt', 'thinking_type', 'thinking_budget_tokens',
+ 'reasoning_effort', 'max_tokens', 'max_retries', 'connect_timeout',
+ 'read_timeout', 'temperature', 'context_win',
+ 'proxy', 'user_agent', 'stream']:
+ if key not in cfg:
+ continue
+ val = cfg[key]
+ if isinstance(val, bool):
+ lines.append(f" '{key}': {str(val)},")
+ elif isinstance(val, (int, float)):
+ lines.append(f" '{key}': {val},")
+ elif isinstance(val, str):
+ lines.append(f" '{key}': '{val}',")
+ else:
+ lines.append(f" '{key}': {repr(val)},")
+
+def _write_platform_value(lines, key, val):
+ """写入顶级变量(平台配置等)"""
+ if isinstance(val, list):
+ if val:
+ lines.append(f"{key} = {repr(val)}")
+ else:
+ lines.append(f"{key} = [] # 允许所有用户")
+ elif isinstance(val, str):
+ lines.append(f"{key} = '{val}'")
+ else:
+ lines.append(f"{key} = {repr(val)}")
+
+
+def _parse_existing_mykey():
+ """解析已有 mykey.py,返回 (model_names, platform_infos)
+
+ model_names: [str] — 模型名列表
+ platform_infos: [{'id': str, 'vars': [{'key': str, 'val': ...}]}] — 平台信息
+ 解析失败时返回 ([], [])
+ """
+ if not os.path.exists(MYKPY_PATH):
+ return [], []
+
+ with open(MYKPY_PATH, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # 解析模型名
+ model_names = []
+ m = re.search(r"'llm_nos':\s*\[([^\]]*)\]", content)
+ if m:
+ model_names = re.findall(r"'([^']+)'", m.group(1))
+
+ # 先收集所有已知平台 env var key → 判断值类型
+ all_env_var_keys = {}
+ platform_env_keys = {} # pid -> [var_key]
+ for p in PLATFORMS:
+ pid = p['id']
+ platform_env_keys.setdefault(pid, [])
+ for var in p.get('env_vars', []):
+ vkey = var['key']
+ all_env_var_keys[vkey] = var
+ platform_env_keys[pid].append(vkey)
+
+ # 逐平台解析所有已知变量
+ platform_infos = []
+ for pid, env_keys in platform_env_keys.items():
+ vars_found = []
+ for vkey in env_keys:
+ var_def = all_env_var_keys[vkey]
+ val = None
+ if var_def.get('is_list'):
+ # 匹配 `xxx = [...]`
+ m_var = re.search(rf"^{vkey}\s*=\s*(\[[^\]]*\])", content, re.MULTILINE)
+ if m_var:
+ try:
+ val = ast.literal_eval(m_var.group(1))
+ except (ValueError, SyntaxError):
+ pass
+ else:
+ # 匹配 `xxx = '...'`
+ m_var = re.search(rf"^{vkey}\s*=\s*'([^']*)'", content, re.MULTILINE)
+ if m_var:
+ val = m_var.group(1)
+ if val is not None:
+ vars_found.append({'key': vkey, 'val': val})
+ if vars_found:
+ platform_infos.append({'id': pid, 'vars': vars_found})
+
+ return model_names, platform_infos
+
+
+def _parse_existing_llm_cfgs():
+ """解析已有 mykey.py,返回完整 LLM 配置字典列表 [{name, apikey, ...}]
+ 解析失败时返回 []
+ """
+ if not os.path.exists(MYKPY_PATH):
+ return []
+
+ with open(MYKPY_PATH, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ cfgs = []
+ # 匹配所有 `xxx = { ... }` 顶层字典赋值
+ # 用简单状态机: 找 `\w+ = {` 然后匹配花括号
+ pattern = re.compile(r'^(\w+)\s*=\s*\{', re.MULTILINE)
+ for m in pattern.finditer(content):
+ brace_start = m.end() - 1 # '{' 的位置
+ depth = 1
+ i = brace_start + 1
+ while i < len(content) and depth > 0:
+ if content[i] == '{':
+ depth += 1
+ elif content[i] == '}':
+ depth -= 1
+ i += 1
+ if depth == 0:
+ dict_text = content[m.end():i - 1]
+ try:
+ d = ast.literal_eval('{' + dict_text + '}')
+ if isinstance(d, dict) and 'name' in d:
+ cfgs.append(d)
+ except (ValueError, SyntaxError):
+ continue
+
+ return cfgs
+
+
+def _backup_with_name(model_names, platform_ids):
+ """按 mykey+模型名+机器人名 格式备份旧 mykey.py"""
+ parts = ['mykey']
+ for m in model_names[:3]:
+ parts.append(m.replace('/', '-').replace('\\', '-'))
+ for pid in platform_ids:
+ pid_clean = pid.replace('_', '')
+ if pid_clean not in parts:
+ parts.append(pid_clean)
+ safe_name = '_'.join(parts)
+ if safe_name == 'mykey':
+ safe_name = 'mykey_backup' # 避免和源文件同名
+ if len(safe_name) > 100:
+ safe_name = safe_name[:100]
+ backup_path = os.path.join(PROJECT_ROOT, f'{safe_name}.py')
+ shutil.copy2(MYKPY_PATH, backup_path)
+ return backup_path
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Main
+# ═══════════════════════════════════════════════════════════════════════════
+
+def main():
+ banner()
+
+ # Python 版本检查
+ ok, msg = _check_python()
+ if not ok:
+ print(f" {C['red']}✗ {msg}{C['reset']}")
+ sys.exit(1)
+ if msg:
+ color = 'yellow' if '⚠' in msg else 'green'
+ print(f" {C[color]}{msg}{C['reset']}\n")
+
+ # ── 决策流程 ──
+ llm_cfgs = []
+ platform_configs = []
+ platform_deps = set()
+ is_modify = False
+ is_new = False
+
+ if os.path.exists(MYKPY_PATH):
+ model_names, platform_infos = _parse_existing_mykey()
+ cur_models = ', '.join(model_names) if model_names else '(未知)'
+ cur_platforms = ', '.join(p['id'] for p in platform_infos) if platform_infos else '(无)'
+ print(f" {C['dim']} 当前: 模型=[{cur_models}], 平台=[{cur_platforms}]{C['reset']}")
+
+ mode = ask_choice(
+ "检测到已有 mykey.py,请选择操作",
+ [
+ {'id': 'modify', 'name': '修改现有配置', 'desc': '保留未改部分,只重新配置选定项'},
+ {'id': 'new', 'name': '新建配置(备份旧文件)', 'desc': '备份为 mykey+模型+平台.py,然后全新配置'},
+ ],
+ default=None,
+ )
+
+ if mode == 'new':
+ backup_path = _backup_with_name(model_names, [p['id'] for p in platform_infos])
+ print(f" {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup_path}{C['reset']}")
+ is_new = True
+ else:
+ is_modify = True
+ scope = ask_choice(
+ "你要修改什么?",
+ [
+ {'id': 'both', 'name': '两项都重新配置', 'desc': 'LLM + 平台全部更新'},
+ {'id': 'llm', 'name': '重新配置 LLM 模型', 'desc': f'当前: {cur_models}'},
+ {'id': 'platform', 'name': '重新配置消息平台', 'desc': f'当前: {cur_platforms}'},
+ ],
+ )
+ if scope in ('llm', 'both'):
+ llm_cfgs = _do_llm()
+ if scope in ('platform', 'both'):
+ platform_configs, platform_deps = configure_platforms()
+ if scope == 'llm' and platform_infos:
+ for pi in platform_infos:
+ p = next((x for x in PLATFORMS if x['id'] == pi['id']), None)
+ if p:
+ config_dict = {v['key']: v['val'] for v in pi['vars']}
+ platform_configs.append({'platform': p, 'config': config_dict})
+ elif scope == 'platform' and model_names:
+ old_cfgs = _parse_existing_llm_cfgs()
+ if old_cfgs:
+ llm_cfgs = old_cfgs
+ print(f"\n {C['green']}✓ 已保留现有 LLM 配置: {', '.join(c['name'] for c in old_cfgs)}{C['reset']}")
+ else:
+ print(f"\n {C['yellow']}⚠ 保留 LLM 配置失败,将生成空配置。建议两项都重新配置。{C['reset']}")
+
+ if not is_modify:
+ if is_new:
+ hint = "已备份旧配置,请完成全新设置"
+ else:
+ hint = "首次配置,建议同时设置模型和消息平台"
+ print(f" {C['dim']} {hint}。{C['reset']}")
+
+ scope = ask_choice(
+ "你想配置什么?",
+ [
+ {'id': 'both', 'name': '两项都配置 (推荐)', 'desc': 'LLM 模型 + 消息平台,完整初始化'},
+ {'id': 'llm', 'name': '仅 LLM 模型', 'desc': '只配置模型,稍后再配平台'},
+ {'id': 'platform', 'name': '仅消息平台', 'desc': '只配平台,稍后再配模型'},
+ ],
+ default='both',
+ )
+
+ if scope in ('llm', 'both'):
+ llm_cfgs = _do_llm()
+ if scope == 'llm':
+ if ask_yesno("是否继续配置消息平台?", default=True):
+ platform_configs, platform_deps = configure_platforms()
+
+ if scope == 'both':
+ platform_configs, platform_deps = configure_platforms()
+
+ if scope == 'platform':
+ platform_configs, platform_deps = configure_platforms()
+ if ask_yesno("是否继续配置 LLM 模型?", default=True):
+ llm_cfgs = _do_llm()
+ elif os.path.exists(MYKPY_PATH):
+ # 新建+仅平台:从备份保留旧 LLM 配置
+ old_cfgs = _parse_existing_llm_cfgs()
+ if old_cfgs:
+ llm_cfgs = old_cfgs
+ print(f"\n {C['green']}✓ 已保留备份中的 LLM 配置: {', '.join(c['name'] for c in old_cfgs)}{C['reset']}")
+
+ # ── 生成 mykey.py ──
+ if not llm_cfgs and not platform_configs:
+ print(f"\n {C['yellow']}⚠ 没有配置任何内容,退出。{C['reset']}")
+ sys.exit(0)
+
+ content = generate_mykey(llm_cfgs, platform_configs)
+
+ # 备份旧文件(修改模式不备份,直接在原文件修改)
+ if os.path.exists(MYKPY_PATH) and not is_modify and not is_new:
+ backup = _backup_with_name(model_names, [p['id'] for p in platform_infos])
+ print(f"\n {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup}{C['reset']}")
+
+ # 写入
+ with open(MYKPY_PATH, 'w', encoding='utf-8') as f:
+ f.write(content)
+ print(f"\n {C['green']}✓ mykey.py 已生成!{C['reset']}")
+
+ # ── 完成提示 ──
+ print(f"\n{C['bold']}{C['green']}╔══════════════════════════════════════╗")
+ print(f"║ 配置完成! ║")
+ print(f"╚══════════════════════════════════════╝{C['reset']}")
+ print()
+ if llm_cfgs:
+ print(f" {C['cyan']} 终端 REPL:{C['reset']} python agentmain.py")
+ if platform_configs:
+ for i, pc in enumerate(platform_configs, 1):
+ p = pc['platform']
+ print(f" {C['cyan']} 平台 {i} ({p['name']}):{C['reset']} python {p['file']}")
+ print()
+
+ # pip 依赖提示
+ all_deps = sorted(platform_deps)
+ if all_deps:
+ print(f" {C['yellow']}💡 提示:你需要安装以下依赖以使消息平台正常工作:{C['reset']}")
+ print(f" {C['cyan']}pip install {' '.join(all_deps)}{C['reset']}")
+ print()
+
+ # ── 入门示例 ──
+ print(f" {C['bold']}试试这些命令:{C['reset']}")
+ examples = [
+ "帮我在桌面创建一个 hello.txt,内容是 Hello World",
+ "请查看你的代码,安装所有用得上的 python 依赖",
+ "执行 web setup sop,解锁 web 工具",
+ "打开淘宝,搜索 iPhone 16,按价格排序",
+ "用rapidocr配置你的ocr能力并存入记忆",
+ "git 更新你的代码,然后看看 commit 有什么新功能",
+ "把这个记到你的记忆里",
+ ]
+ for ex in examples:
+ print(f" {C['dim']}{ex}{C['reset']}")
+ print()
+
+ print(f" {C['green']}{C['bold']}合抱之木,生于毫末{C['reset']}\n")
+
+
+def _do_llm():
+ """配置 LLM 模型,失败则 exit。"""
+ cfgs = configure_llms()
+ if not cfgs:
+ print(f"\n {C['red']}✗ 至少需要配置一个模型才能使用。退出。{C['reset']}")
+ sys.exit(1)
+ return cfgs
+
+
+if __name__ == '__main__':
+ try:
+ main()
+ except KeyboardInterrupt:
+ print(f"\n\n {C['yellow']}⚠ 用户中断{C['reset']}")
+ sys.exit(0)
diff --git a/assets/cookie_grabber/background.js b/assets/cookie_grabber/background.js
deleted file mode 100644
index cdff0aa7c..000000000
--- a/assets/cookie_grabber/background.js
+++ /dev/null
@@ -1,24 +0,0 @@
-// background.js - 保留原有事件 + 新增消息监听
-chrome.runtime.onInstalled.addListener(() => {
- console.log('Cookie Grabber installed');
-});
-
-// content script 请求cookie时的处理
-chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
- if (msg.type === 'getCookies' && sender.tab) {
- const url = sender.tab.url;
- // 普通cookie + partitioned cookie 双查合并
- chrome.cookies.getAll({url}, cookies => {
- console.log('[CookieGrabber] normal cookies:', cookies.map(c => c.name));
- chrome.cookies.getAll({url, partitionKey: {topLevelSite: url.match(/^https?:\/\/[^/]+/)[0]}}, pCookies => {
- console.log('[CookieGrabber] partitioned cookies:', pCookies.map(c => c.name));
- const map = {};
- cookies.forEach(c => map[c.name] = c.value);
- pCookies.forEach(c => map[c.name] = c.value);
- const str = Object.entries(map).map(([k,v]) => k + '=' + v).join('; ');
- sendResponse({cookies: str});
- });
- });
- return true; // 异步响应
- }
-});
\ No newline at end of file
diff --git a/assets/cookie_grabber/content.js b/assets/cookie_grabber/content.js
deleted file mode 100644
index 64ad502dc..000000000
--- a/assets/cookie_grabber/content.js
+++ /dev/null
@@ -1,18 +0,0 @@
-// content.js - MutationObserver 监听触发元素
-const TRIGGER_ID = '__ljqcg__';
-
-const obs = new MutationObserver(muts => {
- for (const m of muts) {
- for (const node of m.addedNodes) {
- if (node.nodeType === 1 && node.id === TRIGGER_ID) {
- chrome.runtime.sendMessage({type: 'getCookies'}, res => {
- if (res && res.cookies) node.textContent = res.cookies;
- else node.textContent = '__cg_error__';
- });
- return;
- }
- }
- }
-});
-
-obs.observe(document.documentElement, {childList: true, subtree: true});
diff --git a/assets/cookie_grabber/manifest.json b/assets/cookie_grabber/manifest.json
deleted file mode 100644
index 1e41399bd..000000000
--- a/assets/cookie_grabber/manifest.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "manifest_version": 3,
- "name": "Cookie Grabber",
- "version": "1.0",
- "description": "获取所有 cookies",
- "permissions": [
- "cookies",
- "storage",
- "tabs",
- "activeTab"
- ],
- "background": {
- "service_worker": "background.js"
- },
- "action": {
- "default_popup": "popup.html"
- },
- "host_permissions": [
- ""
- ],
- "content_scripts": [
- {
- "matches": [
- ""
- ],
- "js": [
- "content.js"
- ],
- "run_at": "document_idle"
- }
- ]
-}
\ No newline at end of file
diff --git a/assets/cookie_grabber/popup.html b/assets/cookie_grabber/popup.html
deleted file mode 100644
index b25f8a49d..000000000
--- a/assets/cookie_grabber/popup.html
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
- HttpOnly Cookie Grabber
-
-
-
-
- Get All Cookies
-
- Refresh and Copy Cookies
-
-
\ No newline at end of file
diff --git a/assets/cookie_grabber/popup.js b/assets/cookie_grabber/popup.js
deleted file mode 100644
index 397943518..000000000
--- a/assets/cookie_grabber/popup.js
+++ /dev/null
@@ -1,56 +0,0 @@
-document.addEventListener('DOMContentLoaded', () => {
- // 当刷新按钮被点击时,获取当前页面的 cookies
- document.getElementById('refresh').addEventListener('click', fetchCookies);
- // 加载时显示当前页面的 cookies
- fetchCookies();
-});
-
-// 从 cookies 存储中获取当前页面的 cookies
-function fetchCookies() {
- // 获取当前活动的标签页
- chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
- const activeTab = tabs[0]; // 获取活动标签页
- const currentUrl = activeTab.url; // 当前页面的 URL
- console.log("当前活动的 URL:", currentUrl);
-
- // 双查: 普通 + partitioned
- const origin = currentUrl.match(/^https?:\/\/[^\/]+/)[0];
- chrome.cookies.getAll({ url: currentUrl }, (cookies) => {
- chrome.cookies.getAll({ url: currentUrl, partitionKey: { topLevelSite: origin } }, (pCookies) => {
- const map = {};
- cookies.forEach(c => map[c.name] = c.value);
- pCookies.forEach(c => map[c.name] = c.value);
- const allCookies = Object.entries(map);
-
- const cookiesDisplay = document.getElementById('cookiesDisplay');
- cookiesDisplay.innerHTML = '';
-
- if (allCookies.length === 0) {
- cookiesDisplay.innerHTML = 'No available cookies ';
- } else {
- let cookiesString = '';
- allCookies.forEach(([name, value]) => {
- const li = document.createElement('li');
- li.textContent = `${name}: ${value}`;
- cookiesDisplay.appendChild(li);
- cookiesString += `${name}=${value}; `;
- });
- console.log('cookies:', allCookies.length);
- copyCookiesToClipboard(cookiesString.trim());
- }
- });
- });
- });
-}
-
-// 将 cookies 复制到剪贴板
-function copyCookiesToClipboard(cookiesString) {
- // 使用 Clipboard API 复制到剪贴板
- navigator.clipboard.writeText(cookiesString)
- .then(() => {
- console.log("Cookies copied to clipboard:", cookiesString);
- })
- .catch(err => {
- console.error("Unable to copy to clipboard:", err);
- });
-}
\ No newline at end of file
diff --git a/assets/demo/codeg-demo.gif b/assets/demo/codeg-demo.gif
new file mode 100644
index 000000000..cd120effb
Binary files /dev/null and b/assets/demo/codeg-demo.gif differ
diff --git a/assets/demo/discord_hcaptcha_real_browser.gif b/assets/demo/discord_hcaptcha_real_browser.gif
new file mode 100644
index 000000000..5630e8cda
Binary files /dev/null and b/assets/demo/discord_hcaptcha_real_browser.gif differ
diff --git a/assets/ga_httpapp.py b/assets/ga_httpapp.py
new file mode 100644
index 000000000..c479e9cbb
--- /dev/null
+++ b/assets/ga_httpapp.py
@@ -0,0 +1,125 @@
+import threading, sys, os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from fastapi import FastAPI, Header, HTTPException, Query, Depends; from fastapi.responses import HTMLResponse
+from pydantic import BaseModel
+from agentmain import GenericAgent as GA
+
+PORT, API_KEY = int(sys.argv[1]), sys.argv[2]
+app, agent, lock = FastAPI(), GA(), threading.Lock()
+outputs, stopped = [], True
+threading.Thread(target=agent.run, daemon=True).start()
+class Req(BaseModel): prompt: str = ""
+agent.verbose = False
+
+def require_key(key: str = Query(None), x_api_key: str = Header(None, alias="X-API-Key")):
+ if API_KEY not in (key, x_api_key): raise HTTPException(404)
+
+def run_task(prompt):
+ global stopped
+ segs = [] # 本任务按 turn 索引的分段输出
+ with lock: task_start = len(outputs)
+ def flush():
+ with lock: outputs[task_start:] = segs
+ try:
+ dq = agent.put_task(prompt, source="http")
+ while "done" not in (item := dq.get(timeout=2200)):
+ outs = item.get("outputs")
+ if not outs: continue
+ idx = max(0, int(item.get("turn", 0) or 0) - 1) # turn 1-based → 槽位 0-based
+ while len(segs) <= idx: segs.append("")
+ segs[idx] = str(outs[-1]) # 当前 turn
+ if len(outs) >= 2 and idx >= 1: segs[idx - 1] = str(outs[-2]) # 前一 turn 落定值
+ flush()
+ segs = [str(s) for s in item.get("outputs", [])] # done 时全量替换
+ flush()
+ finally: stopped = True
+
+@app.post("/put_task")
+def put_task(req: Req, _=Depends(require_key)):
+ global stopped
+ with lock:
+ if not stopped: return {"ok": False, "error": "should abort first"}
+ stopped = False
+ threading.Thread(target=run_task, args=(req.prompt,), daemon=True).start()
+ return {"ok": True}
+
+@app.post("/abort")
+def abort(_=Depends(require_key)): agent.abort(); return {"ok": True}
+
+@app.post("/input")
+def input_task(req: Req, _=Depends(require_key)):
+ global stopped
+ if not stopped: agent.intervene = req.prompt; return {"ok": True, "mode": "intervene"}
+ with lock:
+ if not stopped: agent.intervene = req.prompt; return {"ok": True, "mode": "intervene"}
+ stopped = False
+ threading.Thread(target=run_task, args=(req.prompt,), daemon=True).start()
+ return {"ok": True, "mode": "task"}
+
+@app.get("/output")
+def get_output(k: int = Query(5), _=Depends(require_key)):
+ with lock: r = outputs[-k:]
+ return {"stopped": stopped, "output": "\n".join(r),
+ "history": "\n".join(str(h) for h in agent.history)}
+
+@app.get("/llm")
+def llm_ep(llm_no: int = Query(None), _=Depends(require_key)):
+ if llm_no is not None:
+ agent.next_llm(llm_no)
+ return {"llm_no": agent.llm_no, "name": agent.get_llm_name(),
+ "llms": [{"no": i, "name": n, "current": a} for i, n, a in agent.list_llms()]}
+
+@app.get("/sysprompt")
+def sysprompt_ep(text: str = Query(None), _=Depends(require_key)):
+ if text is not None:
+ agent.extra_sys_prompts = [text] if text else []
+ return {"extra_sys_prompts": agent.extra_sys_prompts}
+
+HELP = """GA HTTP 操作协议(所有请求带 ?key=API_KEY,或 Header X-API-Key)
+GET /output?k=N 查看状态:{stopped, output(末N条), history}。stopped=true 表示空闲
+POST /input {prompt} 下发指令:空闲时作为新任务,忙时作为中途干预(intervene)
+POST /abort 中止当前任务
+GET /llm[?llm_no=N] 查/切模型:返回 {llm_no,name,llms:[{no,name,current}]}
+GET /sysprompt[?text]查/设附加系统提示(extra_sys_prompts),text 为空则清空
+纠偏流程:先 GET /output 读 history 判断状态→需要时 POST /input 注入纠偏指令"""
+
+@app.get("/help")
+def help_ep(_=Depends(require_key)): return {"help": HELP}
+
+@app.get("/")
+def ui():
+ return HTMLResponse(f"""
+
+GA Monitor
+
+
+● Loading...
+
+
+
+Send
+""")
+
+if __name__ == "__main__":
+ import uvicorn; uvicorn.run(app, host="0.0.0.0", port=PORT)
diff --git a/assets/ga_install.ps1 b/assets/ga_install.ps1
new file mode 100644
index 000000000..5d5e19501
--- /dev/null
+++ b/assets/ga_install.ps1
@@ -0,0 +1,577 @@
+#requires -version 5.1
+
+<#!
+
+GenericAgent one-click portable deployer for Windows.
+
+
+
+Modes:
+
+ Default/Mainland: download GenericAgent.zip + uv + PortableGit from user's VPS, set China PyPI mirror.
+
+ GLOBAL=1: clone GenericAgent from GitHub; uv and PortableGit also come from GitHub releases; no PyPI mirror.
+
+
+
+Portable components are installed under \.portable:
+
+ uv, Python installed by uv, PortableGit.
+
+#>
+
+param(
+
+ [string]$InstallDir = "$env:USERPROFILE\GenericAgent",
+
+ [string]$PythonVersion = "3.12",
+
+ [switch]$Force
+
+)
+
+
+
+$ErrorActionPreference = "Stop"
+
+
+
+# Make Chinese output reliable in Windows PowerShell 5.1 and redirected logs.
+
+try {
+
+ [Console]::InputEncoding = [System.Text.Encoding]::UTF8
+
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+
+ $OutputEncoding = [System.Text.Encoding]::UTF8
+
+} catch { }
+
+
+
+$RepoUrl = "https://github.com/lsdefine/GenericAgent.git"
+
+$VpsBase = "http://47.101.182.29:9000"
+
+$GaZipUrl = "$VpsBase/files/GenericAgent.zip"
+
+$UvUrl = "$VpsBase/uv/uv-x86_64-pc-windows-msvc.zip"
+
+$GitUrl = "$VpsBase/files/PortableGit-2.54.0-64-bit.7z.exe"
+
+$Deps = @("requests>=2.28", "beautifulsoup4>=4.12", "bottle>=0.12", "simple-websocket-server>=0.4", "streamlit>=1.28")
+
+$MainlandIndex = "https://pypi.tuna.tsinghua.edu.cn/simple"
+
+$GlobalMode = ($env:GLOBAL -eq "1")
+
+if ($GlobalMode) {
+ # GLOBAL=1: fetch everything from GitHub; no mainland endpoints involved.
+ $UvUrl = "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip"
+ $GitUrl = "https://github.com/git-for-windows/git/releases/download/v2.54.0.windows.1/PortableGit-2.54.0-64-bit.7z.exe"
+}
+
+
+
+$GaDir = [IO.Path]::GetFullPath($InstallDir)
+
+$PortableRoot = Join-Path $GaDir ".portable"
+
+$Bin = Join-Path $PortableRoot "bin"
+
+$Cache = Join-Path $PortableRoot "cache"
+
+$Tools = Join-Path $PortableRoot "tools"
+
+$UvZip = Join-Path $Cache "uv-x86_64-pc-windows-msvc.zip"
+
+$GaZip = Join-Path $Cache "GenericAgent.zip"
+
+$GitExeArchive = Join-Path $Cache "PortableGit-2.54.0-64-bit.7z.exe"
+
+$UvExtract = Join-Path $Cache "uv-extract"
+
+$GaExtract = Join-Path $Cache "ga-extract"
+
+$GitDir = Join-Path $Tools "PortableGit"
+
+$UvExe = Join-Path $Bin "uv.exe"
+
+$GitExe = Join-Path $GitDir "bin\git.exe"
+
+$EnvCmd = Join-Path $GaDir "env.cmd"
+
+$EnvPs1 = Join-Path $GaDir "env.ps1"
+
+
+
+function Say($m) { Write-Host "[ga-deploy] $m" -ForegroundColor Cyan }
+
+function Ok($m) { Write-Host "[ok] $m" -ForegroundColor Green }
+
+function Die($m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
+
+function Invoke-Native([scriptblock]$Command) {
+
+ $prevEAP = $ErrorActionPreference
+
+ $ErrorActionPreference = 'Continue'
+
+ try { & $Command } finally { $ErrorActionPreference = $prevEAP }
+
+ return $LASTEXITCODE
+
+}
+
+
+
+function Download-File($Url, $OutFile) {
+
+ New-Item -ItemType Directory -Force -Path (Split-Path $OutFile) | Out-Null
+
+ Say "Downloading $Url"
+
+ $wc = New-Object System.Net.WebClient
+
+ $wc.Headers.Add("User-Agent", "Mozilla/5.0 ga-deploy")
+
+ try { $wc.DownloadFile($Url, $OutFile) } finally { $wc.Dispose() }
+
+ if (!(Test-Path $OutFile) -or ((Get-Item $OutFile).Length -lt 1024)) { Die "Download failed: $Url" }
+
+}
+
+
+
+function Expand-ZipClean($Zip, $Dest) {
+
+ if (Test-Path $Dest) { Remove-Item -Recurse -Force $Dest }
+
+ New-Item -ItemType Directory -Force -Path $Dest | Out-Null
+
+ Expand-Archive -Path $Zip -DestinationPath $Dest -Force
+
+}
+
+
+
+function Copy-DirectoryContents($Src, $Dst) {
+
+ New-Item -ItemType Directory -Force -Path $Dst | Out-Null
+
+ Get-ChildItem -LiteralPath $Src -Force | ForEach-Object {
+
+ Copy-Item -LiteralPath $_.FullName -Destination $Dst -Recurse -Force
+
+ }
+
+}
+
+
+
+Say "Install dir: $GaDir"
+
+Say "Mode: $(if ($GlobalMode) { 'GLOBAL=1 / GitHub clone' } else { 'Mainland / VPS zip' })"
+
+
+
+if ((Test-Path $GaDir) -and $Force) { Remove-Item -Recurse -Force $GaDir }
+
+New-Item -ItemType Directory -Force -Path $GaDir,$PortableRoot,$Bin,$Cache,$Tools | Out-Null
+
+
+
+# uv (GitHub release in GLOBAL mode, user's VPS otherwise)
+
+if (!(Test-Path $UvExe) -or $Force) {
+
+ Download-File $UvUrl $UvZip
+
+ Expand-ZipClean $UvZip $UvExtract
+
+ $foundUv = Get-ChildItem -Path $UvExtract -Recurse -Filter "uv.exe" | Select-Object -First 1
+
+ if (!$foundUv) { Die "uv.exe not found in archive" }
+
+ Copy-Item $foundUv.FullName $UvExe -Force
+
+}
+
+Ok "uv: $(& $UvExe --version)"
+
+
+
+# Configure portable Python location. Mirror only in mainland mode.
+
+$env:UV_PYTHON_INSTALL_DIR = Join-Path $PortableRoot "uv-python"
+
+$env:UV_CACHE_DIR = Join-Path $PortableRoot "uv-cache"
+
+if ($GlobalMode) {
+
+ Remove-Item Env:UV_DEFAULT_INDEX -ErrorAction SilentlyContinue
+
+ Remove-Item Env:PIP_INDEX_URL -ErrorAction SilentlyContinue
+
+} else {
+
+ $env:UV_DEFAULT_INDEX = $MainlandIndex
+
+ $env:PIP_INDEX_URL = $MainlandIndex
+
+}
+
+$env:PATH = "$Bin;$env:PATH"
+
+
+
+
+# Workaround: uv creates minor-version symlinks (junctions) in UV_PYTHON_INSTALL_DIR.
+# If a previous interrupted install left a plain directory, uv fails with os error 4390.
+# Fix: remove any non-junction subdirectory so uv can recreate them cleanly.
+$uvPyDir = $env:UV_PYTHON_INSTALL_DIR
+if (Test-Path $uvPyDir) {
+ Get-ChildItem -LiteralPath $uvPyDir -Directory | ForEach-Object {
+ $attr = $_.Attributes
+ if (($attr -band [IO.FileAttributes]::ReparsePoint) -eq 0) {
+ Say "Removing stale non-junction dir: $($_.Name)"
+ Remove-Item -LiteralPath $_.FullName -Recurse -Force
+ }
+ }
+}
+
+Say "Installing Python $PythonVersion via uv"
+
+$ec = Invoke-Native { & $UvExe python install $PythonVersion }
+
+if ($ec -ne 0) { Die "uv python install failed" }
+
+$PythonExe = (& $UvExe python find $PythonVersion).Trim()
+
+if (!(Test-Path $PythonExe)) { Die "uv installed Python but python.exe was not found" }
+
+Ok "Python: $(& $PythonExe --version)"
+
+
+
+# PortableGit (GitHub release in GLOBAL mode, user's VPS otherwise). Needed for GLOBAL=1 and useful for user shell.
+
+if (!(Test-Path $GitExe) -or $Force) {
+
+ Download-File $GitUrl $GitExeArchive
+
+ if (Test-Path $GitDir) { Remove-Item -Recurse -Force $GitDir }
+
+ New-Item -ItemType Directory -Force -Path $GitDir | Out-Null
+
+ Say "Extracting PortableGit"
+
+ $ec = Invoke-Native { & $GitExeArchive -y -o"$GitDir" | Out-Null }
+
+ if ($ec -ne 0) { Die "PortableGit extraction failed" }
+
+}
+
+if (!(Test-Path $GitExe)) { Die "git.exe missing: $GitExe" }
+
+Ok "Git: $(& $GitExe --version)"
+
+
+
+$PythonDir = Split-Path $PythonExe -Parent
+
+$GitBin = Split-Path $GitExe -Parent
+
+$GitUsrBin = Join-Path $GitDir "usr\bin"
+
+$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;$env:PATH"
+
+
+
+# Fetch/update GenericAgent source.
+
+if ($GlobalMode) {
+
+ Say "Cloning GenericAgent from GitHub"
+
+ $items = @(Get-ChildItem -LiteralPath $GaDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ".portable" })
+
+ if ($items.Count -gt 0) {
+
+ if (!$Force) { Die "Install dir contains files. Re-run with -Force to replace source while preserving portable tools." }
+
+ $items | Remove-Item -Recurse -Force
+
+ }
+
+ $TmpClone = Join-Path $Cache "ga-clone"
+
+ if (Test-Path $TmpClone) { Remove-Item -Recurse -Force $TmpClone }
+
+ $ec = Invoke-Native { & $GitExe clone --depth 1 $RepoUrl $TmpClone }
+
+ if ($ec -ne 0) { Die "git clone failed" }
+
+ Copy-DirectoryContents $TmpClone $GaDir
+
+ Remove-Item -Recurse -Force $TmpClone
+
+} else {
+
+ Say "Downloading GenericAgent package from VPS"
+
+ Download-File $GaZipUrl $GaZip
+
+ Expand-ZipClean $GaZip $GaExtract
+
+ $SrcDir = Join-Path $GaExtract "GenericAgent"
+
+ if (!(Test-Path $SrcDir)) { $SrcDir = $GaExtract }
+
+ $items = @(Get-ChildItem -LiteralPath $GaDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne ".portable" })
+
+ if ($items.Count -gt 0) { $items | Remove-Item -Recurse -Force }
+
+ Copy-DirectoryContents $SrcDir $GaDir
+
+}
+
+Ok "GenericAgent source ready: $GaDir"
+
+
+
+# Install basic dependencies and project in editable mode into portable Python.
+
+Say "Installing GenericAgent dependencies via uv pip"
+
+$installArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe)
+
+if (!$GlobalMode) { $installArgs += @("--index-url", $MainlandIndex) }
+
+$installArgs += $Deps
+
+$ec = Invoke-Native { & $UvExe @installArgs }
+
+if ($ec -ne 0) { Die "dependency install failed" }
+
+
+
+if (Test-Path (Join-Path $GaDir "pyproject.toml")) {
+
+ $projectArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe)
+
+ if (!$GlobalMode) { $projectArgs += @("--index-url", $MainlandIndex) }
+
+ $projectArgs += @("-e", $GaDir)
+
+ $ec = Invoke-Native { & $UvExe @projectArgs }
+
+ if ($ec -ne 0) { Die "editable project install failed" }
+
+}
+
+
+
+# Try-install pywebview (optional UI). Failure is non-fatal.
+
+Say "Attempting to install pywebview (optional, failure is OK)"
+
+$webviewArgs = @("pip", "install", "--break-system-packages", "--python", $PythonExe)
+
+if (!$GlobalMode) { $webviewArgs += @("--index-url", $MainlandIndex) }
+
+$webviewArgs += @("pywebview>=4.0")
+
+$ec = Invoke-Native { & $UvExe @webviewArgs 2>&1 | Out-Null }
+
+if ($ec -ne 0) {
+
+ Write-Host "[warn] pywebview install failed. This is optional." -ForegroundColor Yellow
+
+ Write-Host " On Windows it usually works out of the box." -ForegroundColor Yellow
+
+ Write-Host " If needed later: uv pip install pywebview" -ForegroundColor Yellow
+
+} else {
+
+ Ok "pywebview installed successfully"
+
+}
+
+
+
+# Activation scripts: portable paths are intentionally before system PATH.
+
+if ($GlobalMode) {
+
+@"
+
+@echo off
+
+set "PORTABLE_DEV_ROOT=$PortableRoot"
+
+set "GENERICAGENT_HOME=$GaDir"
+
+set "UV_PYTHON_INSTALL_DIR=$PortableRoot\uv-python"
+
+set "UV_CACHE_DIR=$PortableRoot\uv-cache"
+
+set "PATH=$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;%PATH%"
+
+echo Activated GenericAgent portable env: %GENERICAGENT_HOME%
+
+"@ | Set-Content -Path $EnvCmd -Encoding ASCII
+
+
+
+@"
+
+`$env:PORTABLE_DEV_ROOT = "$PortableRoot"
+
+`$env:GENERICAGENT_HOME = "$GaDir"
+
+`$env:UV_PYTHON_INSTALL_DIR = "$PortableRoot\uv-python"
+
+`$env:UV_CACHE_DIR = "$PortableRoot\uv-cache"
+
+`$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;`$env:PATH"
+
+Write-Host "Activated GenericAgent portable env: `$env:GENERICAGENT_HOME" -ForegroundColor Green
+
+"@ | Set-Content -Path $EnvPs1 -Encoding UTF8
+
+} else {
+
+@"
+
+@echo off
+
+set "PORTABLE_DEV_ROOT=$PortableRoot"
+
+set "GENERICAGENT_HOME=$GaDir"
+
+set "UV_PYTHON_INSTALL_DIR=$PortableRoot\uv-python"
+
+set "UV_CACHE_DIR=$PortableRoot\uv-cache"
+
+set "UV_DEFAULT_INDEX=$MainlandIndex"
+
+set "PIP_INDEX_URL=$MainlandIndex"
+
+set "PATH=$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;%PATH%"
+
+echo Activated GenericAgent portable env: %GENERICAGENT_HOME%
+
+"@ | Set-Content -Path $EnvCmd -Encoding ASCII
+
+
+
+@"
+
+`$env:PORTABLE_DEV_ROOT = "$PortableRoot"
+
+`$env:GENERICAGENT_HOME = "$GaDir"
+
+`$env:UV_PYTHON_INSTALL_DIR = "$PortableRoot\uv-python"
+
+`$env:UV_CACHE_DIR = "$PortableRoot\uv-cache"
+
+`$env:UV_DEFAULT_INDEX = "$MainlandIndex"
+
+`$env:PIP_INDEX_URL = "$MainlandIndex"
+
+`$env:PATH = "$Bin;$PythonDir;$PythonDir\Scripts;$GitBin;$GitUsrBin;`$env:PATH"
+
+Write-Host "Activated GenericAgent portable env: `$env:GENERICAGENT_HOME" -ForegroundColor Green
+
+"@ | Set-Content -Path $EnvPs1 -Encoding UTF8
+
+}
+
+
+
+Ok "Verification:"
+
+& $UvExe --version
+
+& $PythonExe --version
+
+& $GitExe --version
+
+& $PythonExe -c "import requests, bs4, bottle; print('deps ok')"
+
+Write-Host ""
+
+
+
+# Copy mykey template if mykey.py does not exist (GLOBAL mode only)
+
+$MykeyDst = Join-Path $GaDir "mykey.py"
+
+if ($GlobalMode -and !(Test-Path $MykeyDst)) {
+
+ $MykeyTpl = Join-Path $GaDir "mykey_template_en.py"
+
+ if (Test-Path $MykeyTpl) {
+
+ Copy-Item $MykeyTpl $MykeyDst
+
+ Ok "Copied mykey_template_en.py -> mykey.py"
+
+ }
+
+}
+
+
+
+# Final banner
+
+Write-Host ""
+
+if ($GlobalMode) {
+
+ Write-Host @"
+
+╔═══════════════════════════════════════════════╗
+
+║ ✅ GenericAgent installed successfully! ║
+
+╠═══════════════════════════════════════════════╣
+
+║ 📁 Location: $GaDir
+
+║ 🔑 Config: edit mykey.py (copied from template)
+
+║ 🚀 Launch: ga tui / ga launch / ga hub
+
+╚═══════════════════════════════════════════════╝
+
+"@
+
+} else {
+
+ Write-Host @"
+
+╔═══════════════════════════════════════════════╗
+
+║ [OK] GenericAgent 安装完成! ║
+
+╠═══════════════════════════════════════════════╣
+
+║ 安装目录: $GaDir
+
+║ 配置密钥: ga configure
+
+║ 启动: ga tui / ga launch / ga hub
+
+╚═══════════════════════════════════════════════╝
+
+"@
+
+}
+
+Write-Host ""
+
+Write-Host " Activate env: cmd.exe → call `"$EnvCmd`" | PowerShell → . `"$EnvPs1`""
+
diff --git a/assets/ga_install.sh b/assets/ga_install.sh
new file mode 100644
index 000000000..df6ad3227
--- /dev/null
+++ b/assets/ga_install.sh
@@ -0,0 +1,289 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# GenericAgent one-click portable deployer for macOS/Linux.
+# Modes:
+# Default/Mainland: download GenericAgent.zip + uv from user's VPS, set China PyPI mirror.
+# GLOBAL=1: clone GenericAgent from GitHub; uv also from GitHub releases; no PyPI mirror.
+# Portable components are installed under /.portable:
+# uv, Python installed by uv. On macOS/Linux git is expected from system package manager.
+
+INSTALL_DIR="${INSTALL_DIR:-$HOME/GenericAgent}"
+PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
+FORCE="${FORCE:-0}"
+GLOBAL="${GLOBAL:-0}"
+
+REPO_URL="https://github.com/lsdefine/GenericAgent.git"
+VPS_BASE="http://47.101.182.29:9000"
+GA_ZIP_URL="$VPS_BASE/files/GenericAgent.zip"
+MAINLAND_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple"
+DEPS=("requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "streamlit>=1.28")
+
+say(){ printf '\033[36m[ga-deploy]\033[0m %s\n' "$*"; }
+ok(){ printf '\033[32m[ok]\033[0m %s\n' "$*"; }
+die(){ printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
+
+usage(){ cat <<'EOF'
+Usage:
+ bash install_portable_env.sh
+ INSTALL_DIR="$HOME/GenericAgent" PYTHON_VERSION=3.12 FORCE=1 bash install_portable_env.sh
+ GLOBAL=1 bash install_portable_env.sh
+
+Environment variables:
+ INSTALL_DIR Install GenericAgent here. Default: ~/GenericAgent
+ PYTHON_VERSION Python version installed by uv. Default: 3.12
+ FORCE=1 Replace existing source files while preserving/reinstalling portable tools.
+ GLOBAL=1 Clone from GitHub directly and do not set China PyPI mirror.
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage; exit 0; fi
+
+OS="$(uname -s)"
+ARCH="$(uname -m)"
+case "$OS:$ARCH" in
+ Darwin:x86_64) uv_file="uv-x86_64-apple-darwin.tar.gz" ;;
+ Darwin:arm64) uv_file="uv-aarch64-apple-darwin.tar.gz" ;;
+ Linux:x86_64) uv_file="uv-x86_64-unknown-linux-gnu.tar.gz" ;;
+ Linux:aarch64|Linux:arm64) uv_file="uv-aarch64-unknown-linux-gnu.tar.gz" ;;
+ *) die "Unsupported platform: $OS $ARCH" ;;
+esac
+
+GA_DIR="${INSTALL_DIR/#\~/$HOME}"
+case "$GA_DIR" in
+ /*) ;;
+ *) GA_DIR="$PWD/$GA_DIR" ;;
+esac
+mkdir -p "$GA_DIR"
+GA_DIR="$(cd "$GA_DIR" && pwd -P)"
+PORTABLE_ROOT="$GA_DIR/.portable"
+BIN="$PORTABLE_ROOT/bin"
+CACHE="$PORTABLE_ROOT/cache"
+UV_TGZ="$CACHE/$uv_file"
+GA_ZIP="$CACHE/GenericAgent.zip"
+UV_EXTRACT="$CACHE/uv-extract"
+GA_EXTRACT="$CACHE/ga-extract"
+UV_EXE="$BIN/uv"
+ENV_SH="$GA_DIR/env.sh"
+
+mkdir -p "$GA_DIR" "$PORTABLE_ROOT" "$BIN" "$CACHE"
+
+say "Install dir: $GA_DIR"
+if [[ "$GLOBAL" == "1" ]]; then say "Mode: GLOBAL=1 / GitHub clone"; else say "Mode: Mainland / VPS zip"; fi
+
+if [[ "$FORCE" == "1" ]]; then
+ # Preserve .portable if present; remove source files later before deploying.
+ :
+fi
+
+download(){
+ local url="$1" out="$2"
+ mkdir -p "$(dirname "$out")"
+ say "Downloading $url"
+ if command -v curl >/dev/null 2>&1; then
+ curl -fL --retry 3 -A "ga-deploy" -o "$out" "$url"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -O "$out" "$url"
+ else
+ die "curl or wget is required"
+ fi
+ [[ -s "$out" ]] || die "Download failed: $url"
+}
+
+extract_tgz_clean(){
+ local tgz="$1" dest="$2"
+ rm -rf "$dest"; mkdir -p "$dest"
+ tar -xzf "$tgz" -C "$dest"
+}
+
+extract_zip_clean(){
+ local zip="$1" dest="$2"
+ rm -rf "$dest"; mkdir -p "$dest"
+ if command -v unzip >/dev/null 2>&1; then
+ unzip -q "$zip" -d "$dest"
+ else
+ python3 - "$zip" "$dest" <<'PY'
+import sys, zipfile
+with zipfile.ZipFile(sys.argv[1]) as z:
+ z.extractall(sys.argv[2])
+PY
+ fi
+}
+
+copy_contents(){
+ local src="$1" dst="$2"
+ mkdir -p "$dst"
+ (cd "$src" && tar -cf - .) | (cd "$dst" && tar -xf -)
+}
+
+remove_source_files(){
+ shopt -s dotglob nullglob
+ for p in "$GA_DIR"/*; do
+ [[ "$(basename "$p")" == ".portable" ]] && continue
+ rm -rf "$p"
+ done
+ shopt -u dotglob nullglob
+}
+
+# uv: GitHub release in GLOBAL mode, user's VPS otherwise
+if [[ ! -x "$UV_EXE" || "$FORCE" == "1" ]]; then
+ if [[ "$GLOBAL" == "1" ]]; then
+ download "https://github.com/astral-sh/uv/releases/latest/download/$uv_file" "$UV_TGZ"
+ else
+ download "$VPS_BASE/uv/$uv_file" "$UV_TGZ"
+ fi
+ extract_tgz_clean "$UV_TGZ" "$UV_EXTRACT"
+ found_uv="$(find "$UV_EXTRACT" -type f -name uv | head -n 1 || true)"
+ [[ -n "$found_uv" ]] || die "uv not found in archive"
+ cp "$found_uv" "$UV_EXE"
+ chmod +x "$UV_EXE"
+fi
+ok "uv: $($UV_EXE --version)"
+
+export UV_PYTHON_INSTALL_DIR="$PORTABLE_ROOT/uv-python"
+export UV_CACHE_DIR="$PORTABLE_ROOT/uv-cache"
+if [[ "$GLOBAL" == "1" ]]; then
+ unset UV_DEFAULT_INDEX PIP_INDEX_URL
+else
+ export UV_DEFAULT_INDEX="$MAINLAND_INDEX"
+ export PIP_INDEX_URL="$MAINLAND_INDEX"
+fi
+export PATH="$BIN:$PATH"
+
+say "Installing Python $PYTHON_VERSION via uv"
+"$UV_EXE" python install "$PYTHON_VERSION"
+PYTHON_EXE="$($UV_EXE python find "$PYTHON_VERSION")"
+[[ -x "$PYTHON_EXE" ]] || die "uv installed Python but executable was not found"
+ok "Python: $($PYTHON_EXE --version)"
+PYTHON_DIR="$(dirname "$PYTHON_EXE")"
+export PATH="$BIN:$PYTHON_DIR:$PATH"
+
+# git: macOS/Linux use system git. In mainland mode it is not required for source fetch.
+GIT_EXE=""
+if command -v git >/dev/null 2>&1; then
+ GIT_EXE="$(command -v git)"
+ ok "git: $($GIT_EXE --version)"
+elif [[ "$GLOBAL" == "1" ]]; then
+ die "GLOBAL=1 requires git. Install git with your system package manager, then rerun."
+else
+ say "git not found; continuing because mainland mode uses VPS zip. Install git later if needed."
+fi
+
+# Fetch/update GenericAgent source.
+if [[ "$GLOBAL" == "1" ]]; then
+ say "Cloning GenericAgent from GitHub"
+ if [[ -n "$(find "$GA_DIR" -mindepth 1 -maxdepth 1 ! -name .portable -print -quit)" ]]; then
+ [[ "$FORCE" == "1" ]] || die "Install dir contains files. Re-run with FORCE=1 to replace source while preserving portable tools."
+ remove_source_files
+ fi
+ TMP_CLONE="$CACHE/ga-clone"
+ rm -rf "$TMP_CLONE"
+ "$GIT_EXE" clone --depth 1 "$REPO_URL" "$TMP_CLONE"
+ copy_contents "$TMP_CLONE" "$GA_DIR"
+ rm -rf "$TMP_CLONE"
+else
+ say "Downloading GenericAgent package from VPS"
+ download "$GA_ZIP_URL" "$GA_ZIP"
+ extract_zip_clean "$GA_ZIP" "$GA_EXTRACT"
+ SRC_DIR="$GA_EXTRACT/GenericAgent"
+ [[ -d "$SRC_DIR" ]] || SRC_DIR="$GA_EXTRACT"
+ remove_source_files
+ copy_contents "$SRC_DIR" "$GA_DIR"
+fi
+ok "GenericAgent source ready: $GA_DIR"
+
+# Install basic dependencies and project in editable mode into portable Python.
+say "Installing GenericAgent dependencies via uv pip"
+install_args=(pip install --python "$PYTHON_EXE" --break-system-packages)
+if [[ "$GLOBAL" != "1" ]]; then install_args+=(--index-url "$MAINLAND_INDEX"); fi
+install_args+=("${DEPS[@]}")
+"$UV_EXE" "${install_args[@]}"
+
+if [[ -f "$GA_DIR/pyproject.toml" ]]; then
+ project_args=(pip install --python "$PYTHON_EXE" --break-system-packages)
+ if [[ "$GLOBAL" != "1" ]]; then project_args+=(--index-url "$MAINLAND_INDEX"); fi
+ project_args+=(-e "$GA_DIR")
+ "$UV_EXE" "${project_args[@]}"
+fi
+
+# Try-install pywebview (optional UI). Failure is non-fatal.
+say "Attempting to install pywebview (optional, failure is OK)"
+webview_args=(pip install --python "$PYTHON_EXE" --break-system-packages)
+if [[ "$GLOBAL" != "1" ]]; then webview_args+=(--index-url "$MAINLAND_INDEX"); fi
+webview_args+=("pywebview>=4.0")
+if "$UV_EXE" "${webview_args[@]}" 2>/dev/null; then
+ ok "pywebview installed successfully"
+else
+ printf '\033[33m[warn]\033[0m pywebview install failed. This is optional.\n'
+ printf ' On Linux, pywebview requires system GTK/WebKit libraries.\n'
+ printf ' Install them first, e.g.:\n'
+ printf ' Debian/Ubuntu: sudo apt install python3-gi gir1.2-webkit2-4.1 libgirepository1.0-dev\n'
+ printf ' Fedora: sudo dnf install python3-gobject webkit2gtk4.1\n'
+ printf ' macOS: usually works out of the box (uses PyObjC)\n'
+ printf ' Then retry: uv pip install pywebview\n'
+fi
+
+# Activation script: portable paths are intentionally before system PATH.
+if [[ "$GLOBAL" == "1" ]]; then
+ cat > "$ENV_SH" < "$ENV_SH" < mykey.py"
+ fi
+fi
+
+# Final banner
+echo ""
+if [[ "$GLOBAL" == "1" ]]; then
+ cat <" + html.escape("\n".join(lines)) + " "
+
+class _H(BaseHTTPRequestHandler):
+ def do_GET(self):
+ b = _page().encode("utf-8"); self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8"); self.end_headers(); self.wfile.write(b)
+ def do_POST(self):
+ global _TASK_SLUG, _PLANNED
+ if self.path != "/exec": self.send_response(404); self.end_headers(); return
+ n = int(self.headers.get("Content-Length", "0")); req = json.loads(self.rfile.read(n).decode("utf-8"))
+ out = io.StringIO(); err = io.StringIO(); rc = 0
+ with _exec_lock, redirect_stdout(out), redirect_stderr(err):
+ _bind(req["rundir"]); _note("exec: " + req.get("path", "
+