diff --git a/.docker-assets/bun-linux-aarch64-musl.zip b/.docker-assets/bun-linux-aarch64-musl.zip new file mode 100644 index 00000000..9dcdf604 Binary files /dev/null and b/.docker-assets/bun-linux-aarch64-musl.zip differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..6a9d10d8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +node_modules +desktop +docs +release-notes +fixtures/__pycache__ +runtime/__pycache__ +*.log +.tmp-*.log +.env +.env.* +!.env.example +.docker-assets/ +!.docker-assets/bun-linux-aarch64-musl.zip +.DS_Store +build-artifacts +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ec3bd254 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +# HeiCode Server — local API/WebSocket server in a container. +# +# NOTE: +# We intentionally do NOT use `FROM oven/bun:*` here because some environments +# have intermittent Docker Hub TLS proxy issues when pulling that image. +# Instead we use alpine (already commonly cached) and download Bun directly +# from GitHub release assets during build. +# +# Build: docker build -t heicode-server -f Dockerfile . +# Run: see docker-compose.yml at the repo root. + +FROM alpine:latest + +# Bun on Alpine still needs libstdc++/libgcc at runtime. +# Network to Alpine mirrors can be flaky in this environment, so retry. +RUN set -eux; \ + for i in 1 2 3 4 5 6 7 8; do \ + apk add --no-cache libstdc++ libgcc && break; \ + echo "apk add failed (attempt ${i}), retrying..."; \ + sleep 3; \ + done + +# Install Bun (musl build for Alpine on arm64) from a local build asset. +# This avoids flaky outbound TLS during `docker build` in restricted networks. +COPY .docker-assets/bun-linux-aarch64-musl.zip /tmp/bun.zip +RUN busybox unzip -q /tmp/bun.zip -d /tmp \ + && mv /tmp/bun-linux-aarch64-musl/bun /usr/local/bin/bun \ + && chmod +x /usr/local/bin/bun \ + && rm -rf /tmp/bun.zip /tmp/bun-linux-aarch64-musl + +WORKDIR /app + +# Install dependencies in a separate layer to maximise cache hits across +# code-only changes. +COPY package.json bun.lock bunfig.toml preload.ts ./ +RUN bun install --frozen-lockfile + +# Copy the rest of the source. We deliberately *don't* copy the desktop/ +# Tauri tree because the server doesn't need it; it dramatically speeds up +# the build and keeps the image lean. The `desktop/` folder lives in the +# repo only as a sibling project. +COPY tsconfig.json ./ +COPY src ./src +COPY adapters ./adapters +COPY runtime ./runtime +COPY stubs ./stubs +COPY fixtures ./fixtures +COPY bin ./bin + +# Default port the server listens on. +ENV SERVER_PORT=3456 +ENV SERVER_HOST=0.0.0.0 +# Bind to /data so cc-haha's ~/.claude config persists across container restarts. +ENV CLAUDE_CONFIG_DIR=/data/.claude +RUN mkdir -p /data/.claude + +EXPOSE 3456 + +CMD ["bun", "run", "src/server/index.ts"] diff --git a/README.md b/README.md index f136251a..8cb4979d 100644 --- a/README.md +++ b/README.md @@ -1,302 +1,157 @@ -# Claude Code Haha +# HeiCode -

- Claude Code Haha -

- -
- -[![GitHub Stars](https://img.shields.io/github/stars/NanmiCoder/cc-haha?style=social)](https://github.com/NanmiCoder/cc-haha/stargazers) -[![GitHub Forks](https://img.shields.io/github/forks/NanmiCoder/cc-haha?style=social)](https://github.com/NanmiCoder/cc-haha/network/members) -[![GitHub Issues](https://img.shields.io/github/issues/NanmiCoder/cc-haha)](https://github.com/NanmiCoder/cc-haha/issues) -[![GitHub Pull Requests](https://img.shields.io/github/issues-pr/NanmiCoder/cc-haha)](https://github.com/NanmiCoder/cc-haha/pulls) -[![License](https://img.shields.io/github/license/NanmiCoder/cc-haha)](https://github.com/NanmiCoder/cc-haha/blob/main/LICENSE) -[![中文](https://img.shields.io/badge/🇨🇳_中文-当前-blue)](README.md) -[![English](https://img.shields.io/badge/🇺🇸_English-Available-green)](README.en.md) -[![Docs](https://img.shields.io/badge/📖_文档站点-Visit-D97757)](https://claudecode-haha.relakkesyang.org) - -
- -基于 Claude Code 泄露源码修复的**本地可运行版本**,支持接入任意 Anthropic 兼容 API(MiniMax、OpenRouter 等)。在完整 TUI 之外,还补全了 Computer Use(macOS / Windows)、打造了图形化**桌面端**,并支持通过 Telegram / 飞书**完整远程驱动**。 - -

- 功能 · 桌面端 · 架构概览 · 快速开始 · 环境变量 · FAQ · 全局使用 · 更多文档 -

+> 基于 [`cc-haha`](https://github.com/NanmiCoder/cc-haha) 二次开发的 Claude Code 客户端,登录后通过 **TaijiAICloud** 或 **ClawdRouter** 直接使用各家主流大模型,零配置开箱即用。 --- -## 功能 +## 当前形态 -- 完整的 Ink TUI 交互界面(与官方 Claude Code 一致) -- `--print` 无头模式(脚本/CI 场景) -- 支持 MCP 服务器、插件、Skills -- 支持自定义 API 端点和模型([第三方模型使用指南](docs/guide/third-party-models.md)) -- **记忆系统**(跨会话持久化记忆)— [使用指南](docs/memory/01-usage-guide.md) -- **多 Agent 系统**(多代理编排、并行任务、Teams 协作)— [使用指南](docs/agent/01-usage-guide.md) | [实现原理](docs/agent/02-implementation.md) -- **Skills 系统**(可扩展能力插件、自定义工作流)— [使用指南](docs/skills/01-usage-guide.md) | [实现原理](docs/skills/02-implementation.md) -- **Channel 系统**(通过 Telegram/飞书/Discord 等 IM 远程控制 Agent)— [架构解析](docs/channel/01-channel-system.md) -- **Computer Use 桌面控制** — [功能指南](docs/features/computer-use.md) | [架构解析](docs/features/computer-use-architecture.md) -- **桌面端**(Tauri 2 + React 图形化客户端,多标签多会话)— [文档](docs/desktop/) -- 降级 Recovery CLI 模式(`CLAUDE_CODE_FORCE_RECOVERY_CLI=1 ./bin/claude-haha`) +- **登录方式**:用户启动 HeiCode 后只看到两个登录入口 + - **TaijiAICloud**(自建网关,基于 [`new-api`](https://github.com/Calcium-Ion/new-api)) + - **ClawdRouter**(聚合网关) +- **支持模式**: + - 浏览器跳转 OAuth(推荐 / 平台支持后启用) + - 复制 API Key 粘贴登录(兼容入口 / 立即可用) +- **协议**:两个平台均原生支持 Anthropic `/v1/messages`,HeiCode 直接调用,无需中台代理。 +- **模型矩阵**:登录后自动从平台 `/v1/models` 拉取,覆盖 GPT / Claude / Gemini 全家桶。 --- -## 架构概览 +## 项目结构 - - - - - - - - - - - - - -
整体架构
整体架构
请求生命周期
请求生命周期
工具系统
工具系统
多 Agent 架构
多 Agent 架构
终端 UI
终端 UI
权限与安全
权限与安全
服务层
服务层
状态与数据流
状态与数据流
- ---- - -## 桌面端预览 - -

- 下载桌面端 -   - 安装指南 -

- - - - - - - - - - - - -
主界面
主界面
代码编辑
代码编辑 & Diff 视图
权限控制
权限控制 & AI 提问
提供商设置
多提供商管理
定时任务
定时任务
IM 适配器
IM 适配器(Telegram / 飞书)
+``` +heicode/ +├── bin/ # CLI 入口(heicode / claude-haha 兼容别名) +├── src/ # CLI + Server(Bun + TypeScript + Ink) +│ ├── server/ # 桌面端用的本地 HTTP/WS Server +│ │ ├── config/providerPresets.json # ★ 仅保留 TaijiAICloud / ClawdRouter +│ │ ├── api/heicode-auth.ts # ★ 双 Provider 登录入口 +│ │ └── api/providers.ts # ★ 加了 /v1/models 探活 +│ └── ... +├── desktop/ # Tauri 2 + React 桌面端 +└── docs/ # 文档(含 HEICODE-PLAN.md 路线图) +``` --- ## 快速开始 -### 1. 安装 Bun +### 1. 准备依赖 ```bash # macOS / Linux curl -fsSL https://bun.sh/install | bash -# macOS (Homebrew) -brew install bun - # Windows (PowerShell) powershell -c "irm bun.sh/install.ps1 | iex" ``` -> 精简版 Linux 如提示 `unzip is required`,先运行 `apt update && apt install -y unzip` - -### 2. 安装依赖并配置 +### 2. 安装项目 ```bash +cd heicode bun install -cp .env.example .env -# 编辑 .env 填入你的 API Key,详见 docs/guide/env-vars.md ``` -### 3. 启动 - -#### macOS / Linux +### 3. 启动 CLI(终端版) ```bash -./bin/claude-haha # 交互 TUI 模式 -./bin/claude-haha -p "your prompt here" # 无头模式 -./bin/claude-haha --help # 查看所有选项 +./bin/heicode # 交互 TUI +./bin/heicode -p "your prompt" # 无头模式 ``` -#### Windows - -> **前置要求**:必须安装 [Git for Windows](https://git-scm.com/download/win) - -```powershell -# PowerShell / cmd 直接调用 Bun -bun --env-file=.env ./src/entrypoints/cli.tsx - -# 或在 Git Bash 中运行 -./bin/claude-haha -``` - -### 4. 全局使用(可选) - -将 `bin/` 加入 PATH 后可在任意目录启动,详见 [全局使用指南](docs/guide/global-usage.md): +### 4. 启动桌面端 ```bash -export PATH="$HOME/path/to/claude-code-haha/bin:$PATH" -``` - -### 5. 桌面端联调(Desktop) - -如果你在开发或测试 `desktop/` 前端,需要同时启动 API 服务端和桌面前端。 - -#### 5.1 启动服务端 - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha +# Terminal A — 本地 Server SERVER_PORT=3456 bun run src/server/index.ts + +# Terminal B — 桌面前端 +cd desktop && bun run dev --host 127.0.0.1 --port 2024 ``` -可选自检: +浏览器打开 `http://127.0.0.1:2024`。 + +--- + +## HeiCode Auth API + +供桌面端 / CLI 调用的双 Provider 登录入口。 + +| Method · Path | 用途 | +|---|---| +| `GET /api/heicode-auth/providers` | 列出 2 个登录入口(TaijiAICloud / ClawdRouter)+ OAuth 是否就绪 | +| `POST /api/heicode-auth/login` | 粘贴 API Key 登录:校验 → 拉模型 → 保存 → 激活 | +| `POST /api/heicode-auth/oauth/start` | OAuth 启动(**当前为占位**,等平台支持) | +| `GET /api/heicode-auth/oauth/callback` | OAuth 回跳(**当前为占位**,等平台支持) | +| `GET /api/heicode-auth/status` | 当前登录状态 | +| `POST /api/heicode-auth/logout` | 登出 | + +### 粘贴 API Key 登录示例 ```bash -curl http://127.0.0.1:3456/health +curl -X POST http://127.0.0.1:3456/api/heicode-auth/login \ + -H 'Content-Type: application/json' \ + -d '{"providerId":"taijiaicloud","apiKey":"sk-xxx"}' ``` -#### 5.2 启动桌面前端 +返回: -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop -bun run dev --host 127.0.0.1 --port 2024 +```json +{ + "ok": true, + "provider": { + "id": "...", + "presetId": "taijiaicloud", + "name": "TaijiAICloud", + "baseUrl": "https://api.taijiaicloud.com", + "apiFormat": "anthropic", + "models": { "main": "claude-sonnet-4-6", "haiku": "...", "sonnet": "...", "opus": "..." } + }, + "availableModels": [{ "id": "..." }, ...] +} ``` -然后在浏览器打开: +--- -```text -http://127.0.0.1:2024 -``` +## 给两个平台的对接清单 -#### 5.3 常见注意事项 +详见 [`docs/HEICODE-PLAN.md`](./docs/HEICODE-PLAN.md)。简版: -- 如果 `3456` 端口已经被旧服务端占用,先执行 `lsof -nP -iTCP:3456 -sTCP:LISTEN` 找到 PID,再 `kill `。 -- 测试聊天时建议新建一个 session,并重新选择一个真实存在的工作目录。 -- 如果某个旧 session 绑定的目录已被删除,服务端会返回 `Working directory does not exist`,这和服务端是否启动是两回事。 +### TaijiAICloud(基于 new-api) + +- 必备:`/v1/messages`(Anthropic 原生协议) · `/v1/models` · API Key 子分组管理 +- 推荐:OAuth2 Authorization Code + PKCE 端点;Webhook(额度告警) + +### ClawdRouter + +- 必备:`/v1/messages` · `/v1/models`(已支持) +- 推荐:管理面 API(颁发短期 token);OAuth2 端点 --- -## 技术栈 +## 路线图 -| 类别 | 技术 | -|------|------| -| 运行时 | [Bun](https://bun.sh) | -| 语言 | TypeScript | -| 终端 UI | React + [Ink](https://github.com/vadimdemedes/ink) | -| CLI 解析 | Commander.js | -| API | Anthropic SDK | -| 协议 | MCP, LSP | +- [x] **S0 品牌剥离**:包名 / Tauri / Cargo / 安装钩子改为 HeiCode +- [x] **S0 双 Provider 预设**:providerPresets.json 仅保留 TaijiAICloud / ClawdRouter +- [x] **S0 模型自动发现**:`/v1/models` 探活 + 默认模型自动选取 +- [x] **S0 双 Provider 登录后端**:`/api/heicode-auth/*` 完整就绪(OAuth 占位) +- [ ] **S1 桌面端登录页**:替换现有 Settings 的 Provider 列表为 2 卡片登录 +- [ ] **S1 CLI Onboarding**:原 `Onboarding.tsx` / `ConsoleOAuthFlow` 改为 HeiCode 双卡片 +- [ ] **S1 OAuth 真实接入**:等 TaijiAICloud / ClawdRouter 提供 OAuth endpoints 后补全 +- [ ] **S2 模型选择器 UX**:登录后 `/api/providers/:id/models` 渲染下拉选择默认模型 +- [ ] **S2 配额状态栏**:拉平台 `/v1/usage` 实时展示余量 +- [ ] **S3 Taiji Agent 工具融合**:MCP 自动挂载 / Skills 同步 / Agent 导入 --- -## 更多文档 +## License -| 文档 | 说明 | -|------|------| -| [环境变量](docs/guide/env-vars.md) | 完整环境变量参考和配置方式 | -| [第三方模型](docs/guide/third-party-models.md) | 接入 OpenAI / DeepSeek / Ollama 等非 Anthropic 模型 | -| [记忆系统](docs/memory/01-usage-guide.md) | 跨会话持久化记忆的使用与实现 | -| [多 Agent 系统](docs/agent/01-usage-guide.md) | 多代理编排、并行任务执行与 Teams 协作 | -| [Skills 系统](docs/skills/01-usage-guide.md) | 可扩展能力插件、自定义工作流与条件激活 | -| [Channel 系统](docs/channel/01-channel-system.md) | 通过 Telegram/飞书/Discord 等 IM 平台远程控制 Agent | -| [Computer Use](docs/features/computer-use.md) | 桌面控制功能(截屏、鼠标、键盘)— [架构解析](docs/features/computer-use-architecture.md) | -| [桌面端](docs/desktop/) | Tauri 2 + React 图形化客户端 — [快速上手](docs/desktop/01-quick-start.md) \| [架构设计](docs/desktop/02-architecture.md) \| [安装指南](docs/desktop/04-installation.md) | -| [全局使用](docs/guide/global-usage.md) | 在任意目录启动 claude-haha | -| [常见问题](docs/guide/faq.md) | 常见错误排查 | -| [源码修复记录](docs/reference/fixes.md) | 相对于原始泄露源码的修复内容 | -| [项目结构](docs/reference/project-structure.md) | 代码目录结构说明 | +继承上游 cc-haha 的 License。再往上游溯源是 Anthropic Claude Code 泄露源码,**仅供学习研究使用**。 --- -## 赞助与合作 +## 致谢 -本项目由个人利用业余时间维护,欢迎企业或个人赞助支持持续开发,也可洽谈定制、集成或商务合作。 - - - - - - - - - - - - - - - - - - -
赞助商介绍
- - 接口AI
- 接口AI -
-
- 感谢 接口AI 赞助本项目!接口AI 提供官方资源直供与稳定高性能 API 体验,订阅包价格为官方 8 折;使用 专属链接 注册并绑定 GitHub,可领取 3 美元优惠券。 -
- - 胜算云 - - - 感谢 胜算云 赞助本项目!胜算云是面向 AI Native Teams 的工业级 AI 任务并行执行平台,聚合 Claude、ChatGPT、Gemini 等海内外 LLM 及图片、视频多媒体模型算力;官方直连、非逆向,平台 SLA 可用性达 99.7%,可查看 服务状态。平台支持企业专属网关、成本与权限管控、智能路由、安全防护和 BYOK,按量与 tokens plan(即将上线)计费并可开票;使用 专属链接 注册可获 10 元模力及首充 10% 赠送。 -
- -📧 **联系邮箱**:relakkes@gmail.com - ---- - -## ☕ 请作者喝杯咖啡 - -如果这个项目对您有帮助,欢迎打赏支持,您的每一份支持都是我持续更新的动力 ❤️ - - - - - - - -
-微信赞赏
-微信赞赏 -
-支付宝
-支付宝 -
- -Buy Me a Coffee -
-Buy Me a Coffee -
- ---- - -## 感谢 - -感谢以下开源项目和社区实践为本项目提供参考与启发: - -- [React](https://github.com/facebook/react):前端工程与组件化 UI 生态。 -- [Tauri](https://github.com/tauri-apps/tauri):跨端桌面应用能力与工程实践。 -- [cc-switch](https://github.com/farion1231/cc-switch):模型供应商配置能力参考。 - ---- - -## ⭐ Star 趋势图 - -如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 Claude Code Haha! - - - - - - Star History Chart - - - ---- - -## Disclaimer - -本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。 +- 上游 [`cc-haha`](https://github.com/NanmiCoder/cc-haha) — 提供基础工程 +- [`new-api`](https://github.com/Calcium-Ion/new-api) — 模型网关后端 diff --git a/bin/claude-haha b/bin/claude-haha index e488897f..7f356f4e 100755 --- a/bin/claude-haha +++ b/bin/claude-haha @@ -1,30 +1,4 @@ #!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -#Get your current working directory and export it as an environment variable. -export CALLER_DIR="${CALLER_DIR:-$(pwd -W 2>/dev/null || pwd)}" - -cd "$ROOT_DIR" - -# When spawned by the desktop/web server as a child CLI process, -# skip .env loading — the server has already set the correct env -# via cc-haha/settings.json. Loading .env would re-inject stale -# provider keys (e.g., a MiniMax key as ANTHROPIC_API_KEY) that -# override the active provider config. -if [[ "${CC_HAHA_SKIP_DOTENV:-0}" == "1" ]]; then - # Bun auto-loads .env by default; explicitly point to /dev/null to suppress. - ENV_FILE_FLAG="--env-file=/dev/null" -elif [[ -f .env ]]; then - ENV_FILE_FLAG="--env-file=.env" -else - ENV_FILE_FLAG="" -fi - -# Force recovery CLI (simple readline REPL, no Ink TUI) -if [[ "${CLAUDE_CODE_FORCE_RECOVERY_CLI:-0}" == "1" ]]; then - exec bun $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@" -fi - -# Default: full CLI with Ink TUI -exec bun $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@" +# Backwards-compatible shim — forwards to the new HeiCode entrypoint. +# Will be removed in a future release. +exec "$(dirname "${BASH_SOURCE[0]}")/heicode" "$@" diff --git a/bin/heicode b/bin/heicode new file mode 100755 index 00000000..694cd864 --- /dev/null +++ b/bin/heicode @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Get your current working directory and export it as an environment variable. +export CALLER_DIR="${CALLER_DIR:-$(pwd -W 2>/dev/null || pwd)}" + +cd "$ROOT_DIR" + +# When spawned by the desktop/web server as a child CLI process, +# skip .env loading — the server has already set the correct env +# via heicode/settings.json. Loading .env would re-inject stale +# provider keys (e.g., a MiniMax key as ANTHROPIC_API_KEY) that +# override the active provider config. +if [[ "${HEICODE_SKIP_DOTENV:-${CC_HAHA_SKIP_DOTENV:-0}}" == "1" ]]; then + # Bun auto-loads .env by default; explicitly point to /dev/null to suppress. + ENV_FILE_FLAG="--env-file=/dev/null" +elif [[ -f .env ]]; then + ENV_FILE_FLAG="--env-file=.env" +else + ENV_FILE_FLAG="" +fi + +# Force recovery CLI (simple readline REPL, no Ink TUI) +if [[ "${HEICODE_FORCE_RECOVERY_CLI:-${CLAUDE_CODE_FORCE_RECOVERY_CLI:-0}}" == "1" ]]; then + exec bun $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@" +fi + +# Default: full CLI with Ink TUI +exec bun $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@" diff --git a/desktop/package.json b/desktop/package.json index 58511b0f..367fe2d1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { - "name": "claude-code-desktop", + "name": "heicode-desktop", "private": true, - "version": "0.1.8", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 8684eb2a..d8b136ba 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -327,22 +327,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "claude-code-desktop" -version = "0.1.8" -dependencies = [ - "anyhow", - "portable-pty", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-process", - "tauri-plugin-shell", - "tauri-plugin-updater", -] - [[package]] name = "combine" version = "4.6.7" @@ -1325,6 +1309,22 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heicode-desktop" +version = "0.1.0" +dependencies = [ + "anyhow", + "portable-pty", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-updater", +] + [[package]] name = "hex" version = "0.4.3" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 33082700..f28eb2de 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "claude-code-desktop" -version = "0.1.8" +name = "heicode-desktop" +version = "0.1.0" edition = "2021" [lib] -name = "claude_code_desktop_lib" +name = "heicode_desktop_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 26593521..33a5e396 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - claude_code_desktop_lib::run() + heicode_desktop_lib::run() } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 28bd2eb7..c9330e66 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json", - "productName": "Claude Code Haha", - "version": "0.1.8", - "identifier": "com.claude-code-haha.desktop", + "productName": "HeiCode", + "version": "0.1.0", + "identifier": "com.heicode.desktop", "build": { "frontendDist": "../dist", "devUrl": "http://localhost:1420", @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "Claude Code Haha", + "title": "HeiCode", "width": 1440, "height": 960, "minWidth": 960, @@ -30,9 +30,7 @@ "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK", - "endpoints": [ - "https://github.com/NanmiCoder/cc-haha/releases/latest/download/latest.json" - ], + "endpoints": [], "windows": { "installMode": "passive" } diff --git a/desktop/src-tauri/tauri.macos.conf.json b/desktop/src-tauri/tauri.macos.conf.json index 9405fbbc..ad558ca2 100644 --- a/desktop/src-tauri/tauri.macos.conf.json +++ b/desktop/src-tauri/tauri.macos.conf.json @@ -2,7 +2,7 @@ "app": { "windows": [ { - "title": "Claude Code Haha", + "title": "HeiCode", "width": 1440, "height": 960, "minWidth": 960, diff --git a/desktop/src-tauri/tauri.windows.conf.json b/desktop/src-tauri/tauri.windows.conf.json index ba908a16..663538e0 100644 --- a/desktop/src-tauri/tauri.windows.conf.json +++ b/desktop/src-tauri/tauri.windows.conf.json @@ -2,7 +2,7 @@ "app": { "windows": [ { - "title": "Claude Code Haha", + "title": "HeiCode", "width": 1440, "height": 960, "minWidth": 960, diff --git a/desktop/src-tauri/windows-installer-hooks.nsh b/desktop/src-tauri/windows-installer-hooks.nsh index 0ff18713..00d2d7ac 100644 --- a/desktop/src-tauri/windows-installer-hooks.nsh +++ b/desktop/src-tauri/windows-installer-hooks.nsh @@ -1,5 +1,5 @@ !macro NSIS_HOOK_PREINSTALL - DetailPrint "Stopping running Claude Code Haha sidecars..." + DetailPrint "Stopping running HeiCode sidecars..." nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe' Pop $0 nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-aarch64-pc-windows-msvc.exe' @@ -10,7 +10,9 @@ !macroend !macro NSIS_HOOK_PREUNINSTALL - DetailPrint "Stopping running Claude Code Haha processes..." + DetailPrint "Stopping running HeiCode processes..." + nsExec::ExecToLog 'taskkill /F /T /IM heicode-desktop.exe' + Pop $0 nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe' Pop $0 nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe' diff --git a/desktop/src/api/heicodeAuth.ts b/desktop/src/api/heicodeAuth.ts new file mode 100644 index 00000000..8457fb1e --- /dev/null +++ b/desktop/src/api/heicodeAuth.ts @@ -0,0 +1,84 @@ +// desktop/src/api/heicodeAuth.ts +// +// HeiCode 双 Provider 登录后端的客户端封装。 +// 对应路由: src/server/api/heicode-auth.ts + +import { api } from './client' + +export type HeicodeProviderId = 'taijiaicloud' | 'clawdrouter' + +export type HeicodeLoginProviderInfo = { + id: HeicodeProviderId + name: string + baseUrl: string + websiteUrl: string + apiKeyUrl?: string + promoText?: string + defaultModels: { main: string; haiku: string; sonnet: string; opus: string } + oauthEnabled: boolean + oauthStartUrl: string | null +} + +export type HeicodeAuthStatus = { + loggedIn: boolean + source: 'cc-haha-provider' | 'original-settings' | 'env' | 'none' + activeProvider: { + id: string + presetId: string + name: string + baseUrl: string + models: { main: string; haiku: string; sonnet: string; opus: string } + } | null +} + +export type HeicodeLoginInput = { + providerId: HeicodeProviderId + apiKey: string + displayName?: string +} + +export type HeicodeLoginResult = { + ok: true + provider: { + id: string + presetId: string + name: string + baseUrl: string + apiFormat: 'anthropic' | 'openai_chat' | 'openai_responses' + models: { main: string; haiku: string; sonnet: string; opus: string } + } + availableModels: Array<{ id: string; owned_by?: string }> +} + +export const heicodeAuthApi = { + listProviders() { + return api.get<{ providers: HeicodeLoginProviderInfo[] }>( + '/api/heicode-auth/providers', + ) + }, + + status() { + return api.get('/api/heicode-auth/status') + }, + + loginWithApiKey(input: HeicodeLoginInput) { + return api.post( + '/api/heicode-auth/login', + input, + // 登录会调上游 /v1/models 探活,给 60s 余量。 + { timeout: 60_000 }, + ) + }, + + // OAuth 当前是占位,但保留方法形态以便未来无痛切换。 + startOAuth(providerId: HeicodeProviderId) { + return api.post<{ authorizeUrl: string; state: string }>( + '/api/heicode-auth/oauth/start', + { providerId }, + ) + }, + + logout() { + return api.post<{ ok: true }>('/api/heicode-auth/logout') + }, +} diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index a1af3279..7d55e744 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -3,8 +3,10 @@ import { Sidebar } from './Sidebar' import { ContentRouter } from './ContentRouter' import { ToastContainer } from '../shared/Toast' import { UpdateChecker } from '../shared/UpdateChecker' +import { HeicodeLoginPage } from '../login/HeicodeLoginPage' import { useSettingsStore } from '../../stores/settingsStore' import { useUIStore, type SettingsTab } from '../../stores/uiStore' +import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore' import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts' import { initializeDesktopServerUrl } from '../../lib/desktopRuntime' import { TabBar } from './TabBar' @@ -16,6 +18,9 @@ import { useTranslation } from '../../i18n' export function AppShell() { const fetchSettings = useSettingsStore((s) => s.fetchAll) const sidebarOpen = useUIStore((s) => s.sidebarOpen) + const fetchAuth = useHeicodeAuthStore((s) => s.fetch) + const authStatus = useHeicodeAuthStore((s) => s.status) + const authHasFetched = useHeicodeAuthStore((s) => s.hasFetched) const [ready, setReady] = useState(false) const [startupError, setStartupError] = useState(null) const t = useTranslation() @@ -27,6 +32,9 @@ export function AppShell() { try { await initializeDesktopServerUrl() await fetchSettings() + // Pull HeiCode login state up-front so the shell can decide whether + // to render the login page or the workspace. + await fetchAuth() // Restore tabs from localStorage await useTabStore.getState().restoreTabs() @@ -51,7 +59,7 @@ export function AppShell() { return () => { cancelled = true } - }, [fetchSettings]) + }, [fetchSettings, fetchAuth]) // Listen for macOS native menu navigation events (About / Settings) useEffect(() => { @@ -85,6 +93,18 @@ export function AppShell() { ) } + // Gate the workspace behind a successful HeiCode login. We treat any of the + // existing auth sources (heicode-auth provider, original ~/.claude/settings.json, + // or process env) as logged-in to avoid forcing existing users to re-login. + if (authHasFetched && !authStatus?.loggedIn) { + return ( + <> + + + + ) + } + return (
void +} + +export function HeicodeLoginPage({ onLoggedIn }: Props) { + const t = useTranslation() + const { + providers, + hasFetched, + isLoading, + error, + fetch: fetchAuth, + stopOAuthPolling, + } = useHeicodeAuthStore() + + useEffect(() => { + if (!hasFetched) { + void fetchAuth() + } + return () => { + stopOAuthPolling() + } + }, [hasFetched, fetchAuth, stopOAuthPolling]) + + return ( +
+
+

+ HeiCode +

+

+ {t('login.subtitle')} +

+
+ + {!hasFetched && isLoading ? ( +
+ {t('common.loading')} +
+ ) : null} + + {hasFetched && providers.length === 0 ? ( +
+ {t('login.errors.noProviders')} +
+ ) : null} + + {hasFetched && providers.length > 0 ? ( +
+ {providers.map((provider) => ( + + ))} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +

+ {t('login.footnote')} +

+
+ ) +} diff --git a/desktop/src/components/login/ProviderLoginCard.tsx b/desktop/src/components/login/ProviderLoginCard.tsx new file mode 100644 index 00000000..26b5e5d5 --- /dev/null +++ b/desktop/src/components/login/ProviderLoginCard.tsx @@ -0,0 +1,236 @@ +// desktop/src/components/login/ProviderLoginCard.tsx +// +// 单个登录入口卡片。两种登录方式同时呈现: +// 1. 浏览器跳转 OAuth (推荐) —— 平台支持时启用 +// 2. 复制粘贴 API Key —— 兼容入口 + +import { useState } from 'react' +import { open as shellOpen } from '@tauri-apps/plugin-shell' +import type { HeicodeLoginProviderInfo } from '../../api/heicodeAuth' +import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore' +import { useTranslation } from '../../i18n' + +type Props = { + provider: HeicodeLoginProviderInfo + onLoggedIn?: () => void +} + +/** + * Treat any of the following as a "local override": + * - http:// (not https://) — typical for in-cluster gateways + * - localhost / 127.0.0.1 + * - 10/172.16-31/192.168 RFC1918 ranges + * - any hostname without a dot (e.g. `new-api`, `heicode-server`) — Docker DNS aliases + */ +function isLocalBaseUrl(rawUrl: string): boolean { + if (!rawUrl) return false + let host: string + try { + const url = new URL(rawUrl) + if (url.protocol === 'http:') return true + host = url.hostname + } catch { + return false + } + if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') return true + if (!host.includes('.')) return true + if (host.startsWith('10.')) return true + if (host.startsWith('192.168.')) return true + if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true + return false +} + +export function ProviderLoginCard({ provider, onLoggedIn }: Props) { + const t = useTranslation() + const { loginWithApiKey, startOAuth, startOAuthPolling, isLoggingIn } = + useHeicodeAuthStore() + const [apiKey, setApiKey] = useState('') + const [showKey, setShowKey] = useState(false) + const [localError, setLocalError] = useState(null) + const [busy, setBusy] = useState<'oauth' | 'paste' | null>(null) + + const handleOAuth = async () => { + if (!provider.oauthEnabled) return + setLocalError(null) + setBusy('oauth') + try { + const { authorizeUrl } = await startOAuth(provider.id) + try { + await shellOpen(authorizeUrl) + } catch { + setLocalError(t('login.errors.openBrowser')) + } + startOAuthPolling() + } catch (err) { + setLocalError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(null) + } + } + + const handlePasteLogin = async () => { + if (!apiKey.trim()) { + setLocalError(t('login.errors.emptyKey')) + return + } + setLocalError(null) + setBusy('paste') + try { + await loginWithApiKey({ providerId: provider.id, apiKey: apiKey.trim() }) + setApiKey('') + onLoggedIn?.() + } catch (err) { + setLocalError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(null) + } + } + + const handleOpenKeyPage = async () => { + if (!provider.apiKeyUrl) return + try { + await shellOpen(provider.apiKeyUrl) + } catch { + // ignore — desktop tauri only + } + } + + const local = isLocalBaseUrl(provider.baseUrl) + + return ( +
+
+

+ {provider.name} +

+ {provider.oauthEnabled ? ( + + {t('login.tags.recommended')} + + ) : ( + + {t('login.tags.comingSoon')} + + )} +
+ + {/* Always-visible baseUrl indicator. Helps the user immediately tell + whether this card is pointing at the public TaijiAICloud endpoint + or has been overridden to a local new-api dev gateway. */} +
+ {t('login.baseUrl.label')} + + {provider.baseUrl} + + {local ? ( + + {t('login.baseUrl.localTag')} + + ) : null} +
+ + {provider.promoText ? ( +

+ {provider.promoText} +

+ ) : null} + + {local ? ( +

+ {t('login.baseUrl.localBanner', { id: provider.id.toUpperCase() })} +

+ ) : null} + +
+ + {!provider.oauthEnabled ? ( +

+ {t('login.oauth.disabledHint')} +

+ ) : null} +
+ +
+ + {t('login.divider.or')} + +
+ +
+ +
+ setApiKey(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + void handlePasteLogin() + } + }} + placeholder={t('login.paste.placeholder')} + className="flex-1 bg-transparent px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none" + autoComplete="off" + spellCheck={false} + data-testid={`heicode-login-key-input-${provider.id}`} + /> + +
+ +
+ + {provider.apiKeyUrl ? ( + + ) : null} + + {localError ? ( +
+ {localError} +
+ ) : null} +
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ca59f1e5..cbce0458 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -952,6 +952,31 @@ export const en = { 'serverVerb.Task started': 'Task started', 'serverVerb.Task in progress': 'Task in progress', + // ─── HeiCode Login ────────────────────────────────────── + 'login.subtitle': 'Choose how to sign in to start using HeiCode', + 'login.footnote': 'HeiCode talks directly to TaijiAICloud or ClawdRouter; your API key never leaves this machine.', + 'login.tags.recommended': 'Recommended', + 'login.tags.comingSoon': 'Coming soon', + 'login.oauth.button': 'Sign in with browser', + 'login.oauth.opening': 'Opening browser…', + 'login.oauth.disabled': 'Browser sign-in (coming soon)', + 'login.oauth.disabledHint': 'OAuth will become available once the platform exposes its authorize endpoint.', + 'login.divider.or': 'or', + 'login.paste.label': 'Paste API Key', + 'login.paste.placeholder': 'sk-…', + 'login.paste.show': 'Show', + 'login.paste.hide': 'Hide', + 'login.paste.submit': 'Sign in', + 'login.paste.submitting': 'Verifying…', + 'login.paste.getKey': 'Get API Key', + 'login.errors.openBrowser': 'Failed to open browser. Please copy the URL manually.', + 'login.errors.emptyKey': 'Please paste an API key first.', + 'login.errors.noProviders': 'No login providers configured. Check your HeiCode server.', + 'login.baseUrl.label': 'Endpoint', + 'login.baseUrl.localTag': 'Local', + 'login.baseUrl.localHint': 'Pointing at a private/local address — paste a token issued by THIS endpoint.', + 'login.baseUrl.localBanner': 'This {id} entry has been redirected to a local gateway via env override. Paste an API token from the LOCAL endpoint above (not the public platform).', + // ─── Tabs ────────────────────────────────────── 'tabs.close': 'Close', 'tabs.closeOthers': 'Close Others', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 8350dfbb..546db572 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -954,6 +954,31 @@ export const zh: Record = { 'serverVerb.Task started': '任务已启动', 'serverVerb.Task in progress': '任务进行中', + // ─── HeiCode 登录 ────────────────────────────────────── + 'login.subtitle': '选择登录方式以开始使用 HeiCode', + 'login.footnote': 'HeiCode 直接连 TaijiAICloud 或 ClawdRouter,API Key 仅保存在你这台机器上。', + 'login.tags.recommended': '推荐', + 'login.tags.comingSoon': '即将开放', + 'login.oauth.button': '浏览器登录', + 'login.oauth.opening': '正在打开浏览器…', + 'login.oauth.disabled': '浏览器登录(即将开放)', + 'login.oauth.disabledHint': '等平台开放 OAuth 授权端点后,浏览器登录会立即可用。', + 'login.divider.or': '或', + 'login.paste.label': '粘贴 API Key', + 'login.paste.placeholder': 'sk-…', + 'login.paste.show': '显示', + 'login.paste.hide': '隐藏', + 'login.paste.submit': '登录', + 'login.paste.submitting': '校验中…', + 'login.paste.getKey': '获取 API Key', + 'login.errors.openBrowser': '无法打开浏览器,请手动复制链接。', + 'login.errors.emptyKey': '请先粘贴 API Key。', + 'login.errors.noProviders': '未配置登录入口,请检查 HeiCode 服务端。', + 'login.baseUrl.label': '接口地址', + 'login.baseUrl.localTag': '本地', + 'login.baseUrl.localHint': '当前指向私网/本地地址 — 请粘贴该地址签发的 Token,而不是公网平台的 Token。', + 'login.baseUrl.localBanner': '当前 {id} 入口已被环境变量重定向到本地网关。请粘贴这个本地地址签发的 API Token(不是公网平台的)。', + // ─── Tabs ────────────────────────────────────── 'tabs.close': '关闭', 'tabs.closeOthers': '关闭其他', diff --git a/desktop/src/stores/heicodeAuthStore.ts b/desktop/src/stores/heicodeAuthStore.ts new file mode 100644 index 00000000..5b46943e --- /dev/null +++ b/desktop/src/stores/heicodeAuthStore.ts @@ -0,0 +1,144 @@ +// desktop/src/stores/heicodeAuthStore.ts +// +// HeiCode 全局登录态。AppShell 在启动时调 fetch();登录页里调 loginWithApiKey() / startOAuth()。 + +import { create } from 'zustand' +import { + heicodeAuthApi, + type HeicodeAuthStatus, + type HeicodeLoginInput, + type HeicodeLoginProviderInfo, + type HeicodeLoginResult, +} from '../api/heicodeAuth' + +const OAUTH_POLL_INTERVAL_MS = 2_000 +const OAUTH_POLL_TIMEOUT_MS = 5 * 60_000 + +type HeicodeAuthState = { + status: HeicodeAuthStatus | null + providers: HeicodeLoginProviderInfo[] + hasFetched: boolean + isLoading: boolean + isLoggingIn: boolean + error: string | null + + fetch: () => Promise + refreshStatus: () => Promise + loginWithApiKey: (input: HeicodeLoginInput) => Promise + startOAuth: (providerId: HeicodeLoginProviderInfo['id']) => Promise<{ authorizeUrl: string }> + startOAuthPolling: () => void + stopOAuthPolling: () => void + logout: () => Promise + clearError: () => void +} + +export const useHeicodeAuthStore = create((set, get) => { + let pollTimer: ReturnType | null = null + let pollDeadline = 0 + + const stopPolling = () => { + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + } + + return { + status: null, + providers: [], + hasFetched: false, + isLoading: false, + isLoggingIn: false, + error: null, + + fetch: async () => { + set({ isLoading: true, error: null }) + try { + const [{ providers }, status] = await Promise.all([ + heicodeAuthApi.listProviders(), + heicodeAuthApi.status(), + ]) + set({ providers, status, hasFetched: true, isLoading: false }) + } catch (err) { + set({ + isLoading: false, + error: err instanceof Error ? err.message : String(err), + }) + } + }, + + refreshStatus: async () => { + try { + const status = await heicodeAuthApi.status() + set({ status }) + } catch (err) { + set({ error: err instanceof Error ? err.message : String(err) }) + } + }, + + loginWithApiKey: async (input) => { + set({ isLoggingIn: true, error: null }) + try { + const result = await heicodeAuthApi.loginWithApiKey(input) + // 登录成功后立即刷新状态。 + const status = await heicodeAuthApi.status() + set({ isLoggingIn: false, status }) + return result + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ isLoggingIn: false, error: message }) + throw err + } + }, + + startOAuth: async (providerId) => { + set({ isLoggingIn: true, error: null }) + try { + const res = await heicodeAuthApi.startOAuth(providerId) + set({ isLoggingIn: false }) + return { authorizeUrl: res.authorizeUrl } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ isLoggingIn: false, error: message }) + throw err + } + }, + + startOAuthPolling: () => { + stopPolling() + pollDeadline = Date.now() + OAUTH_POLL_TIMEOUT_MS + const tick = async () => { + if (Date.now() > pollDeadline) { + stopPolling() + return + } + await get().refreshStatus() + if (get().status?.loggedIn) { + stopPolling() + return + } + pollTimer = setTimeout(tick, OAUTH_POLL_INTERVAL_MS) + } + pollTimer = setTimeout(tick, OAUTH_POLL_INTERVAL_MS) + }, + + stopOAuthPolling: () => stopPolling(), + + logout: async () => { + set({ isLoading: true, error: null }) + try { + await heicodeAuthApi.logout() + const status = await heicodeAuthApi.status() + set({ isLoading: false, status }) + } catch (err) { + set({ + isLoading: false, + error: err instanceof Error ? err.message : String(err), + }) + throw err + } + }, + + clearError: () => set({ error: null }), + } +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d5b02d68 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +# HeiCode docker stack +# +# Usage: +# docker compose up -d --build +# curl http://127.0.0.1:3456/health +# curl http://127.0.0.1:3456/api/heicode-auth/providers +# +# This compose joins the existing `new-api_new-api-network` so HeiCode can +# reach the local new-api gateway directly via http://new-api:3000 +# (no need to expose new-api on the host LAN). + +services: + heicode-server: + build: + context: . + dockerfile: Dockerfile + image: heicode-server:latest + container_name: heicode-server + restart: unless-stopped + ports: + - "3456:3456" + environment: + SERVER_PORT: "3456" + SERVER_HOST: "0.0.0.0" + # Auth on the *server* itself is off in dev so the desktop UI can talk + # to it without a bearer. Set to "1" in any non-trivial deployment. + SERVER_AUTH_REQUIRED: "0" + CLAUDE_CONFIG_DIR: "/data/.claude" + # When you want HeiCode to default to the local new-api gateway + # instead of TaijiAICloud / ClawdRouter, point ANTHROPIC_BASE_URL at + # the in-network address. The auth flow can override this at runtime. + # ANTHROPIC_BASE_URL: "http://new-api:3000" + # ANTHROPIC_AUTH_TOKEN: "" + volumes: + - heicode-data:/data + networks: + - default + - new-api-network + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:3456/health"] + interval: 15s + timeout: 5s + retries: 6 + start_period: 20s + +volumes: + heicode-data: + +networks: + # `default` is automatic; we declare it only so we can list `new-api-network` + # alongside it without accidentally dropping the default. + default: + new-api-network: + external: true + name: new-api_new-api-network diff --git a/docs/HEICODE-PLAN.md b/docs/HEICODE-PLAN.md new file mode 100644 index 00000000..c8be9eec --- /dev/null +++ b/docs/HEICODE-PLAN.md @@ -0,0 +1,191 @@ +# HeiCode 产品化沉淀文档 + +> 这份文档把"为什么这么改、改了什么、还差什么"沉淀下来,**作为后续开发与平台对接的唯一权威**。 + +--- + +## 一、产品定位 + +**HeiCode = 开箱即用的 Claude Code**,用户登录平台账号后直接用,不接触 API Key。 + +| 维度 | 设计 | +|---|---| +| 用户 | 个人开发者 | +| 形态 | Claude Code 桌面客户端 + CLI | +| 模型来源 | TaijiAICloud(自建网关,基于 new-api) + ClawdRouter(聚合网关) | +| 计费 | 完全在平台侧(不在 HeiCode 内做任何计费) | +| 中台 | **不需要**。客户端 → 平台直连。| +| 私有化 | **不做**。HeiCode 只对外销售客户端,平台是你公司运营的 SaaS。| + +--- + +## 二、用户登录流程(最终形态) + +``` +启动 HeiCode + ↓ +看到登录页(仅 2 个入口,无第三选项): + ┌───────────────────────┐ ┌──────────────────────┐ + │ TaijiAICloud │ │ ClawdRouter │ + │ • 浏览器跳转 OAuth │ │ • 浏览器跳转 OAuth │ + │ • 粘贴 API Key │ │ • 粘贴 API Key │ + └───────────────────────┘ └──────────────────────┘ + ↓ +登录成功 + ↓ +HeiCode 自动 /v1/models 拉模型列表 → 注入 ANTHROPIC_DEFAULT_*_MODEL + ↓ +正常使用 Claude Code TUI / 桌面端 +``` + +--- + +## 三、已完成(S0 阶段) + +### 1. 品牌剥离 +- [x] `package.json` `name` → `heicode` (`bin: heicode + claude-haha 兼容) +- [x] `bin/heicode` 新可执行入口;`bin/claude-haha` 改为兼容 shim +- [x] `desktop/package.json` `name` → `heicode-desktop` +- [x] `desktop/src-tauri/Cargo.toml` `name` → `heicode-desktop`,`lib.name` → `heicode_desktop_lib` +- [x] `desktop/src-tauri/src/main.rs` 调用更新 +- [x] `desktop/src-tauri/tauri.conf.json` `productName: HeiCode`,`identifier: com.heicode.desktop`,updater endpoint 清空(避免误连上游 release) +- [x] `desktop/src-tauri/tauri.macos.conf.json` / `tauri.windows.conf.json` 窗口标题改为 HeiCode +- [x] `desktop/src-tauri/windows-installer-hooks.nsh` 卸载钩子兼容 `heicode-desktop.exe` + +### 2. Provider 预设重构 +- [x] `src/server/config/providerPresets.json` **仅保留**: + - `official`(保留为内部占位,使原 `activateOfficial()` 调用不破) + - `taijiaicloud`(featured) + - `clawdrouter`(featured) +- 第三方厂商预设(DeepSeek / Kimi / MiniMax 等)全部移除。 + +### 3. 模型自动发现 +- [x] `ProviderService.fetchProviderModels()`: + - 优先 `GET /v1/models` + - 兼容 `GET /models` + - 同时带 `Authorization: Bearer` 和 `x-api-key`,兼容两种平台 + - 智能解析 `data` / `models` / 数组三种返回结构 +- [x] `GET /api/providers/:id/models` — 已保存的 Provider 拉模型 +- [x] `POST /api/providers/models` — 临时 baseUrl + apiKey 拉模型(登录前用) + +### 4. 双 Provider 登录后端 +- [x] 新建 `src/server/api/heicode-auth.ts`,路由挂在 `/api/heicode-auth/*` +- [x] `GET /api/heicode-auth/providers` — 列 2 个登录入口(含 OAuth 启用状态) +- [x] `POST /api/heicode-auth/login` — 粘贴 API Key 登录:校验 → 拉模型 → 保存 → 激活 +- [x] `GET /api/heicode-auth/status` — 当前登录态 +- [x] `POST /api/heicode-auth/logout` — 登出 +- [x] `POST /api/heicode-auth/oauth/start` — OAuth 启动(**占位**) +- [x] `GET /api/heicode-auth/oauth/callback` — OAuth 回跳(**占位**) + +### 5. 文档 +- [x] 根 `README.md` 重写为 HeiCode 中文版 +- [x] 本文件(`docs/HEICODE-PLAN.md`) + +--- + +## 四、待完成 + +### S1 阶段(前端 UI 层) + +#### 桌面端 +- [ ] 替换现有 `desktop/src/pages/Settings.tsx` 的 Provider 区域为「双卡片登录」UI +- [ ] 卡片内:① 浏览器登录按钮(disabled 直到 OAuth 就绪) ② 粘贴 API Key 表单(直接调 `/api/heicode-auth/login`) +- [ ] 登录成功后:自动跳到模型选择页 → 调 `GET /api/providers/:id/models` 渲染下拉 +- [ ] 顶部状态栏显示当前 Provider + 模型 + (未来)配额 + +#### CLI(Ink TUI) +- [ ] 替换 `src/components/Onboarding.tsx` 中的 OAuth 步骤为 HeiCode 登录 +- [ ] 把 `ConsoleOAuthFlow` 替换为 `HeicodeProviderPicker` 组件(双卡片) +- [ ] `src/commands/login/login.tsx` 同步替换 + +> ⚠️ CLI 部分的限制:当前 `src/main.tsx` 是上游 805KB 预构建包,这块 UI 改动需要从源头改并重新走 build pipeline。**第一阶段建议把 CLI 的 onboarding 标记成"先用 desktop 完成登录,CLI 自动读取已登录态"**,等真正打 release 时再把 CLI UI 重 build。 + +### S2 阶段(OAuth 真实接入) + +等 TaijiAICloud / ClawdRouter 平台开放 OAuth 端点后,把 `handleOAuthStart` / `handleOAuthCallback` 占位实装: + +需要平台提供(OAuth2 Authorization Code + PKCE): +1. **Authorize endpoint**:`GET /oauth/authorize?response_type=code&client_id=heicode-desktop&redirect_uri=...&code_challenge=...&state=...&scope=models:read,messages:write` +2. **Token endpoint**:`POST /oauth/token` 接受 `grant_type=authorization_code&code=...&code_verifier=...&client_id=...` +3. **(可选) Refresh endpoint**:`grant_type=refresh_token` +4. **Redirect URI 白名单**:允许 `http://127.0.0.1:/api/heicode-auth/oauth/callback?providerId=` 中的 127.0.0.1 任意端口(参考 Claude Code / GitHub CLI 做法) + +实装位置:`src/server/api/heicode-auth.ts` 中的 `handleOAuthStart` / `handleOAuthCallback`,可以参考已存在的 `src/server/services/hahaOAuthService.ts`(Claude.ai OAuth 实现)抽取通用逻辑。 + +### S3 阶段(差异化) + +- [ ] **配额状态栏**:拉平台 `/v1/usage` 或 new-api `/api/quota/` +- [ ] **多模型快捷切换**:顶部下拉选模型(已具备 `/api/providers/:id/models`) +- [ ] **Taiji Agent 工具融合**:通过 MCP 把 Taiji Agent 的工具挂载进 HeiCode + +--- + +## 五、给 TaijiAICloud / ClawdRouter 的对接 SOW + +### TaijiAICloud(基于 new-api) + +| # | 资产 / 接口 | 状态 | 说明 | +|---|---|---|---| +| 1 | `/v1/messages` Anthropic 原生协议 | **必须确认** | new-api 配置项: 启用 Claude Messages 转发 | +| 2 | `/v1/models` | 应已就绪 | new-api 默认提供 | +| 3 | OAuth2 Authorization Code + PKCE | **待开发** | 见 S2 阶段 | +| 4 | 子 Token 管理(限定 model 白名单 / 每日额度 / 过期) | new-api 自带 | 平台直接复用 | +| 5 | 调用日志 / 审计(≥180天) | new-api 自带 | `/api/log/` | +| 6 | (可选) Webhook 额度告警 | 待开发 | new-api 原生没有 | + +### ClawdRouter + +| # | 资产 / 接口 | 状态 | 说明 | +|---|---|---|---| +| 1 | `/v1/messages` Anthropic 原生协议 | ✅ | [文档](https://www.clawdrouter.com/docs/) | +| 2 | `/v1/chat/completions` OpenAI 协议 | ✅ | 同上 | +| 3 | `/v1/models` | **待确认** | 文档未明示,需要平台确认或补 | +| 4 | OAuth2 端点 | **待开发** | 见 S2 阶段 | +| 5 | 管理面 API(创建/吊销子 Key、用量查询) | 待开发 | 用于 HeiCode 自动颁发 token | +| 6 | `Request-Id` 自定义头 | ✅ | HeiCode 调用时带 `Request-Id: ::` 做归属审计 | + +--- + +## 六、目录与命令速查 + +``` +# 启动桌面端联调 +cd cc-haha +bun install +SERVER_PORT=3456 bun run src/server/index.ts & +cd desktop && bun run dev --host 127.0.0.1 --port 2024 + +# 测登录后端 (粘贴 token 模式) +curl -X POST http://127.0.0.1:3456/api/heicode-auth/login \ + -H 'Content-Type: application/json' \ + -d '{"providerId":"taijiaicloud","apiKey":"sk-xxx"}' + +curl http://127.0.0.1:3456/api/heicode-auth/status +``` + +--- + +## 七、变更清单(git 友好) + +``` +新增: + src/server/api/heicode-auth.ts ← 双 Provider 登录后端 + bin/heicode ← 新 CLI 入口 + README.md ← 完全重写 + docs/HEICODE-PLAN.md ← 本文件 + +修改: + package.json ← name: heicode + bin/claude-haha ← 改为兼容 shim + src/server/api/providers.ts ← 加 /models 端点 + src/server/services/providerService.ts ← 加 fetchProviderModels + src/server/router.ts ← 注册 heicode-auth 路由 + src/server/config/providerPresets.json ← 重写为 2 个 preset + desktop/package.json ← name: heicode-desktop + desktop/src-tauri/Cargo.toml ← name + lib.name + desktop/src-tauri/src/main.rs ← lib 名同步 + desktop/src-tauri/tauri.conf.json ← productName + identifier + updater 清空 + desktop/src-tauri/tauri.macos.conf.json ← 窗口标题 + desktop/src-tauri/tauri.windows.conf.json ← 窗口标题 + desktop/src-tauri/windows-installer-hooks.nsh ← 进程名兼容 +``` diff --git a/package.json b/package.json index 575fff06..2ccbb67f 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,16 @@ { - "name": "claude-code-local", - "version": "999.0.0-local", + "name": "heicode", + "version": "0.1.0", "private": true, "type": "module", "bin": { - "claude-haha": "./bin/claude-haha" + "heicode": "./bin/heicode", + "claude-haha": "./bin/heicode" }, "scripts": { - "claude-haha": "bun run ./bin/claude-haha", - "start": "bun run ./bin/claude-haha", + "heicode": "bun run ./bin/heicode", + "claude-haha": "bun run ./bin/heicode", + "start": "bun run ./bin/heicode", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs" diff --git a/src/server/api/heicode-auth.ts b/src/server/api/heicode-auth.ts new file mode 100644 index 00000000..aa4b14b6 --- /dev/null +++ b/src/server/api/heicode-auth.ts @@ -0,0 +1,290 @@ +/** + * HeiCode Auth API — TaijiAICloud / ClawdRouter 双 Provider 登录入口。 + * + * 设计目标: + * - 用户启动 HeiCode 后看到 2 个登录卡片 (TaijiAICloud / ClawdRouter)。 + * - 每张卡片提供两种登录方式: + * A. 浏览器跳转 OAuth (推荐) —— 平台支持时启用,本端零改动。 + * B. 粘贴 API Key (兼容入口) —— 立即可用,不依赖平台改造。 + * - 登录成功后, HeiCode 自动调用 /v1/models 拉模型列表, 让用户挑默认模型。 + * + * 路由: + * GET /api/heicode-auth/providers + * — 返回 2 个登录入口的元数据 + 当前 OAuth 是否就绪。 + * POST /api/heicode-auth/login + * — { providerId, apiKey, displayName? } → 校验 → 拉模型 → 保存为 SavedProvider → 激活。 + * POST /api/heicode-auth/oauth/start + * — { providerId } → 返回 authorize URL (调起本端 callback listener)。 [STUB] + * GET /api/heicode-auth/oauth/callback + * — 平台回跳: code + state → 换 token → 保存为 SavedProvider → 激活。 [STUB] + * GET /api/heicode-auth/status + * — 当前是否登录 / 哪个 Provider / 模型预览。 + * POST /api/heicode-auth/logout + * — 清掉当前 active provider (不删除 saved 列表里的记录)。 + * + * NOTE: OAuth 部分目前是占位实现,等 TaijiAICloud / ClawdRouter 平台开放 + * OAuth2 Authorization Code + PKCE 端点之后再补。 + * 平台需要提供: + * 1. authorize endpoint (浏览器登录页面) + * 2. token endpoint (code → access_token) + * 3. (可选) refresh endpoint + * 约定 redirect_uri = http://127.0.0.1:/api/heicode-auth/oauth/callback?providerId= + */ + +import { z } from 'zod' +import { ProviderService } from '../services/providerService.js' +import { PROVIDER_PRESETS } from '../config/providerPresets.js' +import { ApiError, errorResponse } from '../middleware/errorHandler.js' + +const providerService = new ProviderService() + +const SUPPORTED_LOGIN_PROVIDER_IDS = ['taijiaicloud', 'clawdrouter'] as const +type SupportedLoginProviderId = (typeof SUPPORTED_LOGIN_PROVIDER_IDS)[number] + +function isSupportedLoginProvider(id: string): id is SupportedLoginProviderId { + return (SUPPORTED_LOGIN_PROVIDER_IDS as readonly string[]).includes(id) +} + +const LoginRequestSchema = z.object({ + providerId: z.enum(['taijiaicloud', 'clawdrouter']), + apiKey: z.string().min(8, 'API Key 长度过短'), + displayName: z.string().min(1).optional(), +}) + +const OAuthStartSchema = z.object({ + providerId: z.enum(['taijiaicloud', 'clawdrouter']), +}) + +export async function handleHeicodeAuthApi( + req: Request, + url: URL, + segments: string[], +): Promise { + try { + const action = segments[2] + const subAction = segments[3] + + // GET /api/heicode-auth/providers + if (action === 'providers' && req.method === 'GET') { + return Response.json({ providers: listLoginProviders() }) + } + + // POST /api/heicode-auth/login + if (action === 'login' && req.method === 'POST') { + return await handleLoginWithApiKey(req) + } + + // GET /api/heicode-auth/status + if (action === 'status' && req.method === 'GET') { + const status = await providerService.checkAuthStatus() + const { providers, activeId } = await providerService.listProviders() + const active = activeId ? providers.find(p => p.id === activeId) : null + return Response.json({ + loggedIn: status.hasAuth, + source: status.source, + activeProvider: active + ? { + id: active.id, + presetId: active.presetId, + name: active.name, + baseUrl: active.baseUrl, + models: active.models, + } + : null, + }) + } + + // POST /api/heicode-auth/logout + if (action === 'logout' && req.method === 'POST') { + await providerService.activateOfficial() // clears active provider + return Response.json({ ok: true }) + } + + // /api/heicode-auth/oauth/* + if (action === 'oauth') { + if (subAction === 'start' && req.method === 'POST') { + return await handleOAuthStart(req) + } + if (subAction === 'callback' && req.method === 'GET') { + return await handleOAuthCallback(url) + } + throw notFound() + } + + throw notFound() + } catch (err) { + return errorResponse(err) + } +} + +// ─── Login helpers ───────────────────────────────────────────── + +type LoginProviderInfo = { + id: SupportedLoginProviderId + name: string + baseUrl: string + websiteUrl: string + apiKeyUrl?: string + promoText?: string + defaultModels: { main: string; haiku: string; sonnet: string; opus: string } + /** 是否已配置 OAuth(占位,等平台支持后改为 true) */ + oauthEnabled: boolean + /** OAuth 启动入口(前端按它跳就行);oauthEnabled=false 时为 null */ + oauthStartUrl: string | null +} + +function listLoginProviders(): LoginProviderInfo[] { + return SUPPORTED_LOGIN_PROVIDER_IDS.map(id => { + const preset = PROVIDER_PRESETS.find(p => p.id === id) + if (!preset) { + throw ApiError.internal(`Missing preset for login provider: ${id}`) + } + return { + id, + name: preset.name, + baseUrl: preset.baseUrl, + websiteUrl: preset.websiteUrl, + apiKeyUrl: preset.apiKeyUrl, + promoText: preset.promoText, + defaultModels: preset.defaultModels, + // TODO: 等平台支持 OAuth 后改为 true 并提供 oauthStartUrl + oauthEnabled: false, + oauthStartUrl: null, + } + }) +} + +async function handleLoginWithApiKey(req: Request): Promise { + const body = await parseJsonBody(req) + const parsed = LoginRequestSchema.safeParse(body) + if (!parsed.success) { + throw ApiError.badRequest(parsed.error.issues.map(i => i.message).join('; ')) + } + const { providerId, apiKey, displayName } = parsed.data + + if (!isSupportedLoginProvider(providerId)) { + throw ApiError.badRequest(`Unsupported provider: ${providerId}`) + } + + const preset = PROVIDER_PRESETS.find(p => p.id === providerId) + if (!preset) { + throw ApiError.internal(`Missing preset for provider: ${providerId}`) + } + + // 1. 用 /v1/models 探活 + 拉模型列表 + const probe = await providerService.fetchProviderModels({ + baseUrl: preset.baseUrl, + apiKey, + apiFormat: preset.apiFormat, + }) + + if (probe.models.length === 0) { + throw ApiError.badRequest( + `校验失败: ${probe.error ?? '该 API Key 在 ' + preset.name + ' 上无可用模型'}`, + ) + } + + // 2. 把当前用户已经存过的同 preset provider 找出来,有则更新,没有则创建 + const existing = await providerService.listProviders() + const sameProvider = existing.providers.find(p => p.presetId === preset.id) + + // 选择默认模型: 优先用 preset 里指定的; 如果不在返回列表里,退回到第一个返回的模型 + const availableIds = new Set(probe.models.map(m => m.id)) + const pick = (preferred: string): string => { + if (preferred && availableIds.has(preferred)) return preferred + return probe.models[0]?.id ?? preferred + } + const models = { + main: pick(preset.defaultModels.main), + haiku: pick(preset.defaultModels.haiku), + sonnet: pick(preset.defaultModels.sonnet), + opus: pick(preset.defaultModels.opus), + } + + let saved + if (sameProvider) { + saved = await providerService.updateProvider(sameProvider.id, { + name: displayName ?? sameProvider.name, + apiKey, + baseUrl: preset.baseUrl, + apiFormat: preset.apiFormat, + models, + }) + } else { + saved = await providerService.addProvider({ + presetId: preset.id, + name: displayName ?? preset.name, + apiKey, + baseUrl: preset.baseUrl, + apiFormat: preset.apiFormat, + models, + }) + } + + // 3. 激活成 active provider + await providerService.activateProvider(saved.id) + + return Response.json({ + ok: true, + provider: { + id: saved.id, + presetId: saved.presetId, + name: saved.name, + baseUrl: saved.baseUrl, + apiFormat: saved.apiFormat, + models: saved.models, + }, + availableModels: probe.models, + }) +} + +// ─── OAuth scaffold (TODO: 平台支持后实装) ────────────────────── + +async function handleOAuthStart(req: Request): Promise { + const body = await parseJsonBody(req) + const parsed = OAuthStartSchema.safeParse(body) + if (!parsed.success) { + throw ApiError.badRequest(parsed.error.issues.map(i => i.message).join('; ')) + } + // 占位 —— 等 TaijiAICloud / ClawdRouter 提供 OAuth2 Authorization endpoint 后实装。 + // 实装思路: + // 1. 生成 PKCE codeVerifier + state, 缓存到 in-memory session map + // 2. 拼出 authorizeUrl = `${platform_authorize_endpoint}?response_type=code&client_id=... + // &redirect_uri=http://127.0.0.1:/api/heicode-auth/oauth/callback?providerId=${providerId} + // &code_challenge=...&code_challenge_method=S256&state=...` + // 3. 返回 { authorizeUrl, state } + // 4. 前端用 shell::open 打开 authorizeUrl + throw ApiError.badRequest( + `${parsed.data.providerId} 暂未启用 OAuth,请先用「粘贴 API Key」登录。OAuth 功能将在平台支持后开放。`, + ) +} + +async function handleOAuthCallback(_url: URL): Promise { + // 占位 —— 配合 handleOAuthStart 使用。流程: + // 1. 校验 state → 取出 codeVerifier + // 2. 调平台 token endpoint: code + codeVerifier → access_token + refresh_token + // 3. 用拿到的 access_token 调 /v1/models 验证 + 保存为 SavedProvider + 激活 + // 4. 返回成功页面给浏览器, 关闭 tab + return new Response( + ` +

OAuth 回跳通道未启用

+

请先在 HeiCode 中使用「粘贴 API Key」方式登录。

+

OAuth 功能将在 TaijiAICloud / ClawdRouter 平台支持后开放。

+ `, + { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, + ) +} + +// ─── Helpers ─────────────────────────────────────────────────── + +async function parseJsonBody(req: Request): Promise> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} + +function notFound(): ApiError { + return new ApiError(404, 'Not Found', 'NOT_FOUND') +} diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts index 2c574f5b..b36f3f63 100644 --- a/src/server/api/providers.ts +++ b/src/server/api/providers.ts @@ -13,6 +13,8 @@ * POST /api/providers/official — activate official (clear env) * POST /api/providers/:id/test — test a saved provider * POST /api/providers/test — test unsaved config + * GET /api/providers/:id/models — fetch /v1/models for a saved provider + * POST /api/providers/models — fetch /v1/models for ad-hoc baseUrl + apiKey (used during login flow) */ import { z } from 'zod' @@ -41,6 +43,11 @@ export async function handleProvidersApi( return await handleTestUnsaved(req) } + // POST /api/providers/models — fetch models given baseUrl + apiKey (used by login flow) + if (id === 'models' && req.method === 'POST') { + return await handleFetchModelsUnsaved(req) + } + // GET /api/providers/presets if (id === 'presets' && req.method === 'GET') { return Response.json({ presets: PROVIDER_PRESETS }) @@ -102,6 +109,18 @@ export async function handleProvidersApi( return Response.json({ result }) } + // GET /api/providers/:id/models — fetch /v1/models using saved credentials + if (action === 'models') { + if (req.method !== 'GET') throw methodNotAllowed(req.method) + const provider = await providerService.getProvider(id) + const result = await providerService.fetchProviderModels({ + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + apiFormat: provider.apiFormat, + }) + return Response.json(result) + } + // /api/providers/:id if (req.method === 'GET') { const provider = await providerService.getProvider(id) @@ -157,6 +176,24 @@ async function handleTestUnsaved(req: Request): Promise { } } +const FetchModelsSchema = z.object({ + baseUrl: z.string().url(), + apiKey: z.string().min(1), + apiFormat: z.enum(['anthropic', 'openai_chat', 'openai_responses']).optional(), +}) + +async function handleFetchModelsUnsaved(req: Request): Promise { + const body = await parseJsonBody(req) + try { + const input = FetchModelsSchema.parse(body) + const result = await providerService.fetchProviderModels(input) + return Response.json(result) + } catch (err) { + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + throw err + } +} + async function parseJsonBody(req: Request): Promise> { try { return (await req.json()) as Record diff --git a/src/server/config/providerPresets.json b/src/server/config/providerPresets.json index f47c2395..f495d4f1 100644 --- a/src/server/config/providerPresets.json +++ b/src/server/config/providerPresets.json @@ -14,156 +14,45 @@ "websiteUrl": "https://www.anthropic.com/claude-code" }, { - "id": "deepseek", - "name": "DeepSeek", - "baseUrl": "https://api.deepseek.com/anthropic", - "apiFormat": "anthropic", - "defaultModels": { - "main": "deepseek-v4-pro", - "haiku": "deepseek-v4-flash", - "sonnet": "deepseek-v4-pro", - "opus": "deepseek-v4-pro" - }, - "needsApiKey": true, - "websiteUrl": "https://platform.deepseek.com", - "apiKeyUrl": "https://platform.deepseek.com/api_keys" - }, - { - "id": "zhipuglm", - "name": "Zhipu GLM", - "baseUrl": "https://open.bigmodel.cn/api/anthropic", - "apiFormat": "anthropic", - "defaultModels": { - "main": "glm-5.1", - "haiku": "glm-4.5-air", - "sonnet": "glm-5-turbo", - "opus": "glm-5.1" - }, - "needsApiKey": true, - "websiteUrl": "https://open.bigmodel.cn", - "apiKeyUrl": "https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D", - "promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。" - }, - { - "id": "kimi", - "name": "Kimi", - "baseUrl": "https://api.kimi.com/coding", - "apiFormat": "anthropic", - "defaultModels": { - "main": "kimi-k2.6", - "haiku": "kimi-k2.6", - "sonnet": "kimi-k2.6", - "opus": "kimi-k2.6" - }, - "needsApiKey": true, - "websiteUrl": "https://platform.moonshot.cn", - "apiKeyUrl": "https://platform.kimi.com/console/api-keys" - }, - { - "id": "minimax", - "name": "MiniMax", - "baseUrl": "https://api.minimaxi.com/anthropic", - "apiFormat": "anthropic", - "defaultModels": { - "main": "MiniMax-M2.7", - "haiku": "MiniMax-M2.7", - "sonnet": "MiniMax-M2.7", - "opus": "MiniMax-M2.7" - }, - "needsApiKey": true, - "websiteUrl": "https://platform.minimaxi.com", - "apiKeyUrl": "https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link" - }, - { - "id": "jiekouai", - "name": "接口AI", - "baseUrl": "https://api.jiekou.ai/anthropic", + "id": "taijiaicloud", + "name": "TaijiAICloud", + "baseUrl": "https://api.taijiaicloud.com", "apiFormat": "anthropic", "defaultModels": { "main": "claude-sonnet-4-6", "haiku": "claude-haiku-4-5-20251001", "sonnet": "claude-sonnet-4-6", - "opus": "claude-opus-4-7" + "opus": "claude-opus-4-6" }, "needsApiKey": true, - "websiteUrl": "https://jiekou.ai", - "apiKeyUrl": "https://jiekou.ai/referral?invited_code=OBNU3K", - "promoText": "接口AI为 cc-haha 的用户提供官方资源与稳定高性能体验,订阅包价格为官方 8 折;绑定 GitHub 后还可领取 3 美元优惠券。", - "featured": true, - "defaultEnv": { - "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none" - } - }, - { - "id": "shengsuanyun", - "name": "胜算云", - "baseUrl": "https://router.shengsuanyun.com/api", - "apiFormat": "anthropic", - "defaultModels": { - "main": "anthropic/claude-sonnet-4.6", - "haiku": "anthropic/claude-haiku-4.5:thinking", - "sonnet": "anthropic/claude-sonnet-4.6", - "opus": "anthropic/claude-opus-4.7" - }, - "needsApiKey": true, - "websiteUrl": "https://www.shengsuanyun.com", - "apiKeyUrl": "https://www.shengsuanyun.com/?from=CH_LEJ88KWR", - "promoText": "胜算云为 cc-haha 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!", + "websiteUrl": "https://api.taijiaicloud.com", + "apiKeyUrl": "https://api.taijiaicloud.com/dashboard/keys", + "promoText": "TaijiAICloud 是 HeiCode 推荐的国内 LLM 网关,原生支持 Anthropic /v1/messages 协议,覆盖 GPT / Claude / Gemini 等主流模型。", "featured": true, "defaultEnv": { "API_TIMEOUT_MS": "3000000", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", - "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none" + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" } }, { - "id": "lmstudio", - "name": "LM Studio", - "baseUrl": "http://localhost:1234", + "id": "clawdrouter", + "name": "ClawdRouter", + "baseUrl": "https://api.clawdrouter.com", "apiFormat": "anthropic", "defaultModels": { - "main": "qwen/qwen3.6-27b", - "haiku": "qwen/qwen3.6-27b", - "sonnet": "qwen/qwen3.6-27b", - "opus": "qwen/qwen3.6-27b" - }, - "needsApiKey": false, - "websiteUrl": "https://lmstudio.ai/docs/integrations/claude-code", - "promoText": "LM Studio 使用 Anthropic 兼容协议,Base URL 填 http://localhost:1234,不要追加 /v1。Claude Code 的提示词、工具和 Skill 会占用较多上下文,请在本地模型设置里把 Context Window 调大,建议至少 200K。", - "defaultEnv": { - "ANTHROPIC_AUTH_TOKEN": "lmstudio" - } - }, - { - "id": "ollama", - "name": "Ollama", - "baseUrl": "http://localhost:11434", - "apiFormat": "anthropic", - "defaultModels": { - "main": "qwen3.6:27b", - "haiku": "qwen3.6:27b", - "sonnet": "qwen3.6:27b", - "opus": "qwen3.6:27b" - }, - "needsApiKey": false, - "websiteUrl": "https://docs.ollama.com/integrations/claude-code", - "promoText": "Ollama 使用 Anthropic 兼容协议,Base URL 填 http://localhost:11434,不要追加 /v1。Claude Code 的提示词、工具和 Skill 会占用较多上下文,请在本地模型设置里把 Context Window 调大,建议至少 200K。", - "defaultEnv": { - "ANTHROPIC_AUTH_TOKEN": "ollama" - } - }, - { - "id": "custom", - "name": "Custom", - "baseUrl": "", - "apiFormat": "anthropic", - "defaultModels": { - "main": "", - "haiku": "", - "sonnet": "", - "opus": "" + "main": "claude-sonnet-4-6", + "haiku": "claude-haiku-4-5-20251001", + "sonnet": "claude-sonnet-4-6", + "opus": "claude-opus-4-6" }, "needsApiKey": true, - "websiteUrl": "" + "websiteUrl": "https://www.clawdrouter.com", + "apiKeyUrl": "https://www.clawdrouter.com/dashboard/keys", + "promoText": "ClawdRouter 双协议聚合网关,同时支持 Anthropic /v1/messages 与 OpenAI /v1/chat/completions,覆盖 OpenAI / Anthropic / Google 全家桶。", + "featured": true, + "defaultEnv": { + "API_TIMEOUT_MS": "3000000", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" + } } ] diff --git a/src/server/config/providerPresets.ts b/src/server/config/providerPresets.ts index efe3ed7e..5518e299 100644 --- a/src/server/config/providerPresets.ts +++ b/src/server/config/providerPresets.ts @@ -32,4 +32,29 @@ const ProviderPresetsSchema = z.array(ProviderPresetSchema) export type ModelMapping = z.infer export type ProviderPreset = z.infer -export const PROVIDER_PRESETS = ProviderPresetsSchema.parse(providerPresetsJson) +/** + * Per-provider base URL overrides via env. This lets a developer point + * HeiCode at a *local* gateway (e.g. a self-hosted new-api on Docker) + * without editing the preset JSON file. + * + * Examples: + * HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 + * HEICODE_CLAWDROUTER_BASE_URL=http://localhost:4000 + * + * The override is applied at module load time. Restart the server after + * changing these. + */ +const PROVIDER_BASE_URL_ENV_MAP: Record = { + taijiaicloud: 'HEICODE_TAIJIAICLOUD_BASE_URL', + clawdrouter: 'HEICODE_CLAWDROUTER_BASE_URL', +} + +const parsedPresets = ProviderPresetsSchema.parse(providerPresetsJson) + +export const PROVIDER_PRESETS = parsedPresets.map((preset) => { + const envKey = PROVIDER_BASE_URL_ENV_MAP[preset.id] + if (!envKey) return preset + const override = process.env[envKey]?.trim() + if (!override) return preset + return { ...preset, baseUrl: override.replace(/\/+$/, '') } +}) diff --git a/src/server/index.ts b/src/server/index.ts index 5c53c0da..7e6816af 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -56,12 +56,17 @@ export function startServer(port = PORT, host = HOST) { * Auth is required when explicitly opted in or when bound to a non-localhost address. * - Default localhost dev: no auth needed (tests pass as-is). * - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically. - * - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost. + * - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost. + * - Explicit opt-out: SERVER_AUTH_REQUIRED=0 disables auth even on 0.0.0.0 + * (use only in trusted environments — e.g. a Docker container whose port + * is published to host loopback only). Takes precedence over the auto rule. */ - const authRequired = - SERVER_OPTIONS.authRequired || - process.env.SERVER_AUTH_REQUIRED === '1' || - host !== '127.0.0.1' + const authForcedOff = process.env.SERVER_AUTH_REQUIRED === '0' + const authRequired = authForcedOff + ? false + : SERVER_OPTIONS.authRequired || + process.env.SERVER_AUTH_REQUIRED === '1' || + host !== '127.0.0.1' const server = Bun.serve({ port, diff --git a/src/server/router.ts b/src/server/router.ts index 87853152..6c17143b 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -18,6 +18,7 @@ import { handlePluginsApi } from './api/plugins.js' import { handleSkillsApi } from './api/skills.js' import { handleComputerUseApi } from './api/computer-use.js' import { handleHahaOAuthApi } from './api/haha-oauth.js' +import { handleHeicodeAuthApi } from './api/heicode-auth.js' import { handleMcpApi } from './api/mcp.js' export async function handleApiRequest(req: Request, url: URL): Promise { @@ -72,6 +73,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise/v1/models` (OpenAI-compatible — what TaijiAICloud (new-api) and ClawdRouter both expose). + * 2. Fall back to `GET /models` (some Anthropic-flavoured proxies put it there). + * 3. If neither works, return an empty list — the UI should let the user enter a model id manually. + * + * Auth header is chosen based on `apiFormat`: + * - 'anthropic' → both `Authorization: Bearer` AND `x-api-key` (covers TaijiAICloud + ClawdRouter, harmless extras). + * - other → `Authorization: Bearer`. + */ + async fetchProviderModels(input: { + baseUrl: string + apiKey: string + apiFormat?: ApiFormat + }): Promise<{ models: Array<{ id: string; owned_by?: string }>; source: string; error?: string }> { + const base = input.baseUrl.replace(/\/+$/, '') + if (!base) { + return { models: [], source: '', error: 'Missing baseUrl' } + } + + const headers: Record = { 'Content-Type': 'application/json' } + if (input.apiKey) { + headers['Authorization'] = `Bearer ${input.apiKey}` + headers['x-api-key'] = input.apiKey + headers['anthropic-version'] = '2023-06-01' + } + + const candidates = [`${base}/v1/models`, `${base}/models`] + let lastError: string | undefined + + for (const url of candidates) { + try { + const response = await fetch(url, { + method: 'GET', + headers, + signal: AbortSignal.timeout(15000), + }) + if (!response.ok) { + lastError = `HTTP ${response.status} from ${url}` + continue + } + const body = (await response.json().catch(() => null)) as Record | null + const list = extractModelList(body) + if (list.length > 0) { + return { models: list, source: url } + } + lastError = `Empty/unrecognised response from ${url}` + } catch (err) { + if (err instanceof DOMException && err.name === 'TimeoutError') { + lastError = `Timeout calling ${url}` + } else { + lastError = err instanceof Error ? err.message : String(err) + } + } + } + + return { models: [], source: '', error: lastError ?? 'Unable to fetch models' } + } + async testProviderConfig(input: TestProviderInput): Promise { const format: ApiFormat = input.apiFormat ?? 'anthropic' const base = input.baseUrl.replace(/\/+$/, '') @@ -571,6 +634,39 @@ function buildDirectTestRequest( } } +/** + * Normalise responses from /v1/models (OpenAI-style { data: [{ id }] }) + * and other shapes into a flat list of { id, owned_by? }. + */ +function extractModelList(body: unknown): Array<{ id: string; owned_by?: string }> { + if (!body || typeof body !== 'object') return [] + + const entries: unknown[] = Array.isArray((body as { data?: unknown }).data) + ? ((body as { data: unknown[] }).data) + : Array.isArray((body as { models?: unknown }).models) + ? ((body as { models: unknown[] }).models) + : Array.isArray(body) + ? (body as unknown[]) + : [] + + const result: Array<{ id: string; owned_by?: string }> = [] + for (const entry of entries) { + if (typeof entry === 'string') { + result.push({ id: entry }) + continue + } + if (entry && typeof entry === 'object') { + const rec = entry as Record + const id = (rec.id ?? rec.model ?? rec.name) as string | undefined + if (typeof id === 'string' && id.length > 0) { + const owned = rec.owned_by as string | undefined + result.push(owned ? { id, owned_by: owned } : { id }) + } + } + } + return result +} + function validateResponseBody( body: Record | null, format: ApiFormat,