feat: rebrand to HeiCode and add dual-provider auth flow
Rebrand cc-haha to HeiCode and implement TaijiAICloud/ClawdRouter login foundations across server and desktop, including provider presets, model discovery endpoints, and login UI/store scaffolding for OAuth and token paste flows.
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
+59
@@ -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"]
|
||||
@@ -1,302 +1,157 @@
|
||||
# Claude Code Haha
|
||||
# HeiCode
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/app-icon.png" alt="Claude Code Haha" width="240">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/NanmiCoder/cc-haha/stargazers)
|
||||
[](https://github.com/NanmiCoder/cc-haha/network/members)
|
||||
[](https://github.com/NanmiCoder/cc-haha/issues)
|
||||
[](https://github.com/NanmiCoder/cc-haha/pulls)
|
||||
[](https://github.com/NanmiCoder/cc-haha/blob/main/LICENSE)
|
||||
[](README.md)
|
||||
[](README.en.md)
|
||||
[](https://claudecode-haha.relakkesyang.org)
|
||||
|
||||
</div>
|
||||
|
||||
基于 Claude Code 泄露源码修复的**本地可运行版本**,支持接入任意 Anthropic 兼容 API(MiniMax、OpenRouter 等)。在完整 TUI 之外,还补全了 Computer Use(macOS / Windows)、打造了图形化**桌面端**,并支持通过 Telegram / 飞书**完整远程驱动**。
|
||||
|
||||
<p align="center">
|
||||
<a href="#功能">功能</a> · <a href="#桌面端预览">桌面端</a> · <a href="#架构概览">架构概览</a> · <a href="#快速开始">快速开始</a> · <a href="docs/guide/env-vars.md">环境变量</a> · <a href="docs/guide/faq.md">FAQ</a> · <a href="docs/guide/global-usage.md">全局使用</a> · <a href="#更多文档">更多文档</a>
|
||||
</p>
|
||||
> 基于 [`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 全家桶。
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
## 项目结构
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="docs/images/01-overall-architecture.png" alt="整体架构"><br><b>整体架构</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/02-request-lifecycle.png" alt="请求生命周期"><br><b>请求生命周期</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/03-tool-system.png" alt="工具系统"><br><b>工具系统</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/04-multi-agent.png" alt="多 Agent 架构"><br><b>多 Agent 架构</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="docs/images/05-terminal-ui.png" alt="终端 UI"><br><b>终端 UI</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/06-permission-security.png" alt="权限与安全"><br><b>权限与安全</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/07-services-layer.png" alt="服务层"><br><b>服务层</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/08-state-data-flow.png" alt="状态与数据流"><br><b>状态与数据流</b></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 桌面端预览
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NanmiCoder/cc-haha/releases"><img src="https://img.shields.io/badge/⬇_下载桌面端-macOS_%7C_Windows-D97757?style=for-the-badge" alt="下载桌面端"></a>
|
||||
|
||||
<a href="docs/desktop/04-installation.md"><img src="https://img.shields.io/badge/📖_安装指南-Guide-gray?style=for-the-badge" alt="安装指南"></a>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/01_full_ui.png" alt="主界面"><br><b>主界面</b></td>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/02_edit_code.png" alt="代码编辑"><br><b>代码编辑 & Diff 视图</b></td>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/03_ask_question_and_permission.png" alt="权限控制"><br><b>权限控制 & AI 提问</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/05_settings.png" alt="提供商设置"><br><b>多提供商管理</b></td>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/08_scheduled_task.png" alt="定时任务"><br><b>定时任务</b></td>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/07_im.png" alt="IM 适配器"><br><b>IM 适配器(Telegram / 飞书)</b></td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
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 <PID>`。
|
||||
- 测试聊天时建议新建一个 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 泄露源码,**仅供学习研究使用**。
|
||||
|
||||
---
|
||||
|
||||
## 赞助与合作
|
||||
## 致谢
|
||||
|
||||
本项目由个人利用业余时间维护,欢迎企业或个人赞助支持持续开发,也可洽谈定制、集成或商务合作。
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="220">赞助商</th>
|
||||
<th align="left">介绍</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://jiekou.ai/referral?invited_code=OBNU3K">
|
||||
<img src="docs/images/sponsors/jiekou-logo.svg" width="72" alt="接口AI"><br>
|
||||
<strong>接口AI</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
感谢 <a href="https://jiekou.ai/referral?invited_code=OBNU3K">接口AI</a> 赞助本项目!接口AI 提供官方资源直供与稳定高性能 API 体验,订阅包价格为官方 8 折;使用 <a href="https://jiekou.ai/referral?invited_code=OBNU3K">专属链接</a> 注册并绑定 GitHub,可领取 3 美元优惠券。
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">
|
||||
<img src="docs/images/sponsors/shengsuanyun-logo.svg" width="180" alt="胜算云">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
感谢 <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">胜算云</a> 赞助本项目!胜算云是面向 AI Native Teams 的工业级 AI 任务并行执行平台,聚合 Claude、ChatGPT、Gemini 等海内外 LLM 及图片、视频多媒体模型算力;官方直连、非逆向,平台 SLA 可用性达 99.7%,可查看 <a href="https://watch.shengsuanyun.com/status/shengsuanyun">服务状态</a>。平台支持企业专属网关、成本与权限管控、智能路由、安全防护和 BYOK,按量与 tokens plan(即将上线)计费并可开票;使用 <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">专属链接</a> 注册可获 10 元模力及首充 10% 赠送。
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
📧 **联系邮箱**:relakkes@gmail.com
|
||||
|
||||
---
|
||||
|
||||
## ☕ 请作者喝杯咖啡
|
||||
|
||||
如果这个项目对您有帮助,欢迎打赏支持,您的每一份支持都是我持续更新的动力 ❤️
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%">
|
||||
<img src="docs/images/donate/wechat_pay.jpeg" width="250" alt="微信赞赏"><br>
|
||||
<b>微信赞赏</b>
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<img src="docs/images/donate/zfb_pay.png" width="250" alt="支付宝"><br>
|
||||
<b>支付宝</b>
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<a href="https://buymeacoffee.com/relakkes" target="_blank">
|
||||
<img src="docs/images/donate/bmc_button.png" width="250" alt="Buy Me a Coffee">
|
||||
</a><br>
|
||||
<b>Buy Me a Coffee</b>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 感谢
|
||||
|
||||
感谢以下开源项目和社区实践为本项目提供参考与启发:
|
||||
|
||||
- [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!
|
||||
|
||||
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 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) — 模型网关后端
|
||||
|
||||
+3
-29
@@ -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" "$@"
|
||||
|
||||
Executable
+30
@@ -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 "$@"
|
||||
@@ -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",
|
||||
|
||||
Generated
+16
-16
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
claude_code_desktop_lib::run()
|
||||
heicode_desktop_lib::run()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Claude Code Haha",
|
||||
"title": "HeiCode",
|
||||
"width": 1440,
|
||||
"height": 960,
|
||||
"minWidth": 960,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Claude Code Haha",
|
||||
"title": "HeiCode",
|
||||
"width": 1440,
|
||||
"height": 960,
|
||||
"minWidth": 960,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<HeicodeAuthStatus>('/api/heicode-auth/status')
|
||||
},
|
||||
|
||||
loginWithApiKey(input: HeicodeLoginInput) {
|
||||
return api.post<HeicodeLoginResult>(
|
||||
'/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')
|
||||
},
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<>
|
||||
<HeicodeLoginPage />
|
||||
<ToastContainer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden bg-[var(--color-surface)]">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// desktop/src/components/login/HeicodeLoginPage.tsx
|
||||
//
|
||||
// 全屏登录页 — 当 HeiCode 检测到尚未登录时由 AppShell 渲染。
|
||||
// 仅 2 个登录入口:TaijiAICloud / ClawdRouter,没有第三选项。
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
|
||||
import { ProviderLoginCard } from './ProviderLoginCard'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type Props = {
|
||||
onLoggedIn?: () => 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 (
|
||||
<div
|
||||
className="flex h-screen w-screen flex-col items-center justify-center overflow-auto bg-[var(--color-surface)] px-6 py-10"
|
||||
data-testid="heicode-login-page"
|
||||
>
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-semibold text-[var(--color-text-primary)]">
|
||||
HeiCode
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
|
||||
{t('login.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!hasFetched && isLoading ? (
|
||||
<div className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasFetched && providers.length === 0 ? (
|
||||
<div className="rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface-elevated,#fff)] p-4 text-sm text-[var(--color-error)]">
|
||||
{t('login.errors.noProviders')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasFetched && providers.length > 0 ? (
|
||||
<div className="grid w-full max-w-4xl grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{providers.map((provider) => (
|
||||
<ProviderLoginCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
onLoggedIn={onLoggedIn}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 max-w-xl rounded-md bg-[var(--color-error-bg,rgba(220,38,38,.06))] px-4 py-3 text-sm text-[var(--color-error)]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mt-10 max-w-xl text-center text-xs text-[var(--color-text-tertiary)] leading-relaxed">
|
||||
{t('login.footnote')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="flex flex-col gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border-separator)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-card)]">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{provider.name}
|
||||
</h3>
|
||||
{provider.oauthEnabled ? (
|
||||
<span className="rounded-full bg-[var(--color-success-bg,rgba(22,163,74,.1))] px-2 py-0.5 text-xs text-[var(--color-success)]">
|
||||
{t('login.tags.recommended')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-[var(--color-surface-muted,#f0f0f0)] px-2 py-0.5 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('login.tags.comingSoon')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]"
|
||||
data-testid={`heicode-login-card-baseurl-${provider.id}`}
|
||||
>
|
||||
<span>{t('login.baseUrl.label')}</span>
|
||||
<code className="rounded bg-[var(--color-surface-muted,#f0f0f0)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text-secondary)] break-all">
|
||||
{provider.baseUrl}
|
||||
</code>
|
||||
{local ? (
|
||||
<span
|
||||
className="rounded-full bg-[var(--color-warning-bg,rgba(217,119,6,.1))] px-2 py-0.5 text-[10px] uppercase tracking-wide text-[var(--color-warning)]"
|
||||
title={t('login.baseUrl.localHint')}
|
||||
data-testid={`heicode-login-card-local-tag-${provider.id}`}
|
||||
>
|
||||
{t('login.baseUrl.localTag')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{provider.promoText ? (
|
||||
<p className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{provider.promoText}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{local ? (
|
||||
<p className="rounded-md bg-[var(--color-warning-bg,rgba(217,119,6,.06))] px-3 py-2 text-xs text-[var(--color-warning)] leading-relaxed">
|
||||
{t('login.baseUrl.localBanner', { id: provider.id.toUpperCase() })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOAuth}
|
||||
disabled={!provider.oauthEnabled || isLoggingIn || busy !== null}
|
||||
className="rounded-md bg-[image:var(--gradient-btn-primary)] px-4 py-2.5 text-sm text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-opacity hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busy === 'oauth'
|
||||
? t('login.oauth.opening')
|
||||
: provider.oauthEnabled
|
||||
? t('login.oauth.button')
|
||||
: t('login.oauth.disabled')}
|
||||
</button>
|
||||
{!provider.oauthEnabled ? (
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('login.oauth.disabledHint')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span className="h-px flex-1 bg-[var(--color-border-separator)]" />
|
||||
{t('login.divider.or')}
|
||||
<span className="h-px flex-1 bg-[var(--color-border-separator)]" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-medium text-[var(--color-text-secondary)]">
|
||||
{t('login.paste.label')}
|
||||
</label>
|
||||
<div className="flex items-stretch gap-1 rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface-elevated,#fff)] focus-within:border-[var(--color-text-accent)]">
|
||||
<input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => 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}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
className="px-3 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{showKey ? t('login.paste.hide') : t('login.paste.show')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasteLogin}
|
||||
disabled={isLoggingIn || busy !== null || apiKey.trim().length === 0}
|
||||
className="rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] px-4 py-2 text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
data-testid={`heicode-login-key-submit-${provider.id}`}
|
||||
>
|
||||
{busy === 'paste'
|
||||
? t('login.paste.submitting')
|
||||
: t('login.paste.submit')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{provider.apiKeyUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenKeyPage}
|
||||
className="self-start text-xs text-[var(--color-text-accent)] hover:underline"
|
||||
>
|
||||
{t('login.paste.getKey')} →
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{localError ? (
|
||||
<div className="rounded-md bg-[var(--color-error-bg,rgba(220,38,38,.06))] px-3 py-2 text-xs text-[var(--color-error)]">
|
||||
{localError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -954,6 +954,31 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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': '关闭其他',
|
||||
|
||||
@@ -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<void>
|
||||
refreshStatus: () => Promise<void>
|
||||
loginWithApiKey: (input: HeicodeLoginInput) => Promise<HeicodeLoginResult>
|
||||
startOAuth: (providerId: HeicodeLoginProviderInfo['id']) => Promise<{ authorizeUrl: string }>
|
||||
startOAuthPolling: () => void
|
||||
stopOAuthPolling: () => void
|
||||
logout: () => Promise<void>
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export const useHeicodeAuthStore = create<HeicodeAuthState>((set, get) => {
|
||||
let pollTimer: ReturnType<typeof setTimeout> | 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 }),
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -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 <baseUrl>/v1/models`
|
||||
- 兼容 `GET <baseUrl>/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 <platform>/oauth/authorize?response_type=code&client_id=heicode-desktop&redirect_uri=...&code_challenge=...&state=...&scope=models:read,messages:write`
|
||||
2. **Token endpoint**:`POST <platform>/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:<port>/api/heicode-auth/oauth/callback?providerId=<id>` 中的 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: <user>:<session>:<uuid>` 做归属审计 |
|
||||
|
||||
---
|
||||
|
||||
## 六、目录与命令速查
|
||||
|
||||
```
|
||||
# 启动桌面端联调
|
||||
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 ← 进程名兼容
|
||||
```
|
||||
+7
-5
@@ -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"
|
||||
|
||||
@@ -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:<heicode-server-port>/api/heicode-auth/oauth/callback?providerId=<id>
|
||||
*/
|
||||
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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:<port>/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<Response> {
|
||||
// 占位 —— 配合 handleOAuthStart 使用。流程:
|
||||
// 1. 校验 state → 取出 codeVerifier
|
||||
// 2. 调平台 token endpoint: code + codeVerifier → access_token + refresh_token
|
||||
// 3. 用拿到的 access_token 调 /v1/models 验证 + 保存为 SavedProvider + 激活
|
||||
// 4. 返回成功页面给浏览器, 关闭 tab
|
||||
return new Response(
|
||||
`<!doctype html><html><body style="font-family:system-ui;padding:32px;text-align:center">
|
||||
<h2>OAuth 回跳通道未启用</h2>
|
||||
<p>请先在 HeiCode 中使用「粘贴 API Key」方式登录。</p>
|
||||
<p>OAuth 功能将在 TaijiAICloud / ClawdRouter 平台支持后开放。</p>
|
||||
</body></html>`,
|
||||
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
|
||||
function notFound(): ApiError {
|
||||
return new ApiError(404, 'Not Found', 'NOT_FOUND')
|
||||
}
|
||||
@@ -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<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
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<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -32,4 +32,29 @@ const ProviderPresetsSchema = z.array(ProviderPresetSchema)
|
||||
export type ModelMapping = z.infer<typeof ModelMappingSchema>
|
||||
export type ProviderPreset = z.infer<typeof ProviderPresetSchema>
|
||||
|
||||
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<string, string> = {
|
||||
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(/\/+$/, '') }
|
||||
})
|
||||
|
||||
+10
-5
@@ -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<WebSocketData>({
|
||||
port,
|
||||
|
||||
@@ -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<Response> {
|
||||
@@ -72,6 +73,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'haha-oauth':
|
||||
return handleHahaOAuthApi(req, url, segments)
|
||||
|
||||
case 'heicode-auth':
|
||||
return handleHeicodeAuthApi(req, url, segments)
|
||||
|
||||
case 'adapters':
|
||||
return handleAdaptersApi(req, url, segments)
|
||||
|
||||
|
||||
@@ -403,6 +403,69 @@ export class ProviderService {
|
||||
})
|
||||
}
|
||||
|
||||
// --- Models discovery ---
|
||||
|
||||
/**
|
||||
* Fetch the list of models exposed by a provider.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Try `GET <baseUrl>/v1/models` (OpenAI-compatible — what TaijiAICloud (new-api) and ClawdRouter both expose).
|
||||
* 2. Fall back to `GET <baseUrl>/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<string, string> = { '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<string, unknown> | 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<ProviderTestResult> {
|
||||
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<string, unknown>
|
||||
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<string, unknown> | null,
|
||||
format: ApiFormat,
|
||||
|
||||
Reference in New Issue
Block a user