Loading repository data…
Loading repository data…
linmy666 / repository
MadCop — local-first AI agent desktop workstation (v0.9). Multi-model chat, tool-use, MCP servers, 12 workflow modes, knowledge base, AI design tool, persistent workspace.
A local-first AI agent desktop workstation.
MadCop is a cross-platform desktop application that brings the power of modern LLMs into a private, agentic workflow. It runs as a single Electron binary on macOS, Windows, and Linux, talks to any OpenAI-compatible API endpoint, and keeps your conversations, files, and knowledge base entirely on your machine. No cloud lock-in, no per-seat fees, no data leaving the device.
This document explains the why behind the major design decisions — written for product managers and reviewers who want to understand how the system is put together, not just a list of features.
The dominant LLM desktop clients (ChatGPT, Claude.ai, Gemini) are excellent chat surfaces but they assume a specific shape of interaction: one human, one model, one conversation at a time, with vendor-managed tools and memory. That works for "answer this question" but it does not work for "I need to (a) search the web, (b) read a local file, (c) summarise the result, (d) save a Markdown report to disk" — which is a normal afternoon for a product manager, analyst, or engineer.
MadCop is built around three observations:
git history, screenshots, contracts — the substance of real work sits on the user's disk. A client that can only attach a single file per message (or pays per-token for cloud RAG) punishes the people who already have the answer.So the design goal is: a thin local shell that lets the user pick their own model, hand it their own files, and let the system orchestrate the rest. Everything else is in service of that.
┌─────────────────────────────────────────────────────────┐
│ Electron Shell │
│ ┌────────────────────┐ ┌────────────────────────┐ │
│ │ Vue 3 + Pinia + │ │ Python Backend │ │
│ │ Tailwind v4 UI │ │ (FastAPI + Uvicorn) │ │
│ │ (Renderer Process)│←→│ │ │
│ └────────────────────┘ │ ┌──────────────────┐ │ │
│ │ │ LLM Client │ │ │
│ ┌────────────────────┐ │ │ (OpenAI Compat) │ │ │
│ │ Workspace Picker │ │ └──────────────────┘ │ │
│ │ Sidebar / Tabs │ │ ┌──────────────────┐ │ │
│ │ Chat / Composer │ │ │ Tool Registry │ │ │
│ └────────────────────┘ │ │ + MCP Bridge │ │ │
│ │ └──────────────────┘ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ Workflow Engine │ │ │
│ │ │ + 12 Modes │ │ │
│ │ └──────────────────┘ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ Memory Pipeline │ │ │
│ │ │ (5-tier) │ │ │
│ │ └──────────────────┘ │ │
│ └────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
The architecture is intentionally two processes talking over HTTP — not one big monolith — for three reasons:
madcop.server module runs without Electron (over plain HTTP/WebSocket) for CI, scripting, or embedding in other tools.Routing is the single most important design question in any LLM client. MadCop routes on three axes simultaneously:
The user configures one or more model providers in the Settings panel. Each provider is a name + base URL + API key + model id (any OpenAI-compatible endpoint). The frontend exposes the active provider as a useSettingsStore.currentModel ref; the backend receives a model field on every POST /api/chat and forwards it to the OpenAI-compatible client.
This means there is no "default model" hard-coded in the backend. If you don't configure one, you get a clear error on the first request. The model lives in user data, not in the product.
Different models from different vendors have wildly different tool-use quality. The backend's tool dispatcher handles this by tuning the system prompt per model family. The backend's tool dispatcher (madcop/tools/registry.py) registers the same toolset regardless of model, but the system prompt is tuned to nudge the model toward emitting function calls. Specifically, every chat request includes an explicit instruction:
"When the user asks you to do anything that requires real-time information, you MUST call the
web_searchtool. Do not make up answers. Call the tool directly — do not output the tool's parameter description."
This works around the most common failure mode of self-hosted Chinese-tuned models (outputting the JSON schema as text instead of as a structured tool_calls array).
A single chat turn can be either "answer this" (one LLM call) or "search the web, write a report, save to disk" (many LLM calls with tool side effects). The Mode Selector in the composer exposes 12 preset modes from Google's "Agent design patterns" catalog:
single_agent — one LLM + tools, the defaultsequential — Agent A → B → C, each consuming the previous outputparallel — same input fanned out to N workers, results mergedcoordinator — a router agent classifies input, dispatches to a specialisthierarchical — top-level plan, sub-tasks, recurseswarm — many agents with shared blackboardloop — produce, critique, revise until a quality threshold is metreview_critique — producer + revieweriterative_refine — feedback loop with a separate evaluatorhuman_in_loop — explicit user checkpointreact — Reason + Actcustom — user-defined graphWhen a mode is selected, the chat endpoint hands the request to the workflow engine (madcop/workflow/executor.py), which walks the mode's graph of nodes. Each node is a Python class with an execute(ctx) method that receives the inputs from upstream nodes and yields a result. The engine emits SSE events at each step so the UI can show progress live.
For users who don't want to pick a mode, the frontend performs a lightweight keyword-based intent classifier (_classifyAndPlan in chatStore.ts) that detects multi-step requests (contains words like 调研 / 搜索 / 保存 / 生成 / 写到) and injects an inline task plan into the system prompt:
"First, search using web_search. Second, generate the report and save it to ~/Downloads/madcop/ as report.md using write_file. Third, reply to the user when done."
This gives the user the experience of the multi-agent system without the cognitive overhead of picking a mode from a dropdown.
The tool system is the surface where "agent" stops being marketing and becomes real. MadCop's design is a single registry that everything reads from, with three extension points:
Built-in tools — registered in madcop/tools/__init__.py::default_registry(). These include:
web_search (DuckDuckGo, no API key needed)web_fetch (httpx + BeautifulSoup-style HTML→text)read_file / write_file / edit_file (path-confined to allowlisted dirs)weather (wttr.in, no key)clarify (returns a structured question back to the user)MCP servers — any external Model Context Protocol server can be registered at startup via madcop/tools/mcp.py. The bridge translates MCP tools/call into internal Tool.__call__ invocations. This means a user can add a private internal API or a database client without forking MadCop.
User-defined Python tools — any function decorated with @tool("name", description="...") is auto-registered. The decorator captures the signature and produces an OpenAI-compatible JSON schema. This is the path for one-off internal tools.
The dispatcher is stateless: each tool call is registry.dispatch(tool_call) -> ToolResult. The function looks up the tool by name, validates the arguments against the schema, calls the function, and wraps the return value in a ToolResult(is_error=..., content=...). The chat loop serializes the result and appends it to the message history before the second LLM call.
The frontend's ToolCallGroup.vue component reads these results from the SSE stream and renders them as collapsible cards under the assistant message — so the user sees which tools were called, with what inputs, and what was returned.
A core anti-pattern in many LLM clients is that the client doesn't know which directory the user is actually working in, so the model writes files to os.getcwd() of whatever process spawned it. In MadCop, this is solved with a WorkspacePanel component in the sidebar that:
dialog.showOpenDialog).localStorage as madcop_workspace_dir and on the backend via POST /api/workspace/dir.madcop_workspace_dir and prepends a system message: "Your current working directory is /Users/.../madcop. When the user asks you to save files, generate reports, or write code, save them under that directory."The backend's WriteFileTool.__init__ accepts an allowed_dirs list and the chat handler passes [_WORKSPACE_DIR, os.getcwd(), os.path.expanduser('~')] to it. This means a user who picks ~/Documents/projects/foo cannot accidentally have the model write into ~/Library/Application Support/madcop/.
The DirectoryPicker component in the composer echoes the same path so the user always sees where files would go.
The frontend is responsible for local-first persistence of the conversation log via chatStore._persistSession():
sendMessage writes the current messages + title of that session to localStorage["madcop_chat_messages"].getSession() is called the first time a session id is referenced; if the in-memory state has no messages but `l