feat: enhance tool summary integration in messaging services and UI components
- Added tool summary generation in TeamDataService and TeamProvisioningService to capture tool usage details in messages. - Updated InboxMessage type to include toolSummary for better tracking of tool usage in assistant messages. - Enhanced LeadThoughtsGroupRow to aggregate and display total tool usage across thoughts, improving visibility of tool interactions. - Refactored TeamModelSelector to incorporate provider icons and improve user interface for model selection. - Updated README and CLAUDE.md to reflect new command usage and features related to tool summaries.
This commit is contained in:
parent
30b4e74924
commit
9368e7639d
10 changed files with 756 additions and 29 deletions
|
|
@ -8,6 +8,7 @@ Electron 28.x, React 18.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x
|
||||||
## Commands
|
## Commands
|
||||||
Always use pnpm (not npm/yarn) for this project.
|
Always use pnpm (not npm/yarn) for this project.
|
||||||
Do NOT run `pnpm lint:fix` unless the user explicitly asks for it — it interferes with agents running in parallel.
|
Do NOT run `pnpm lint:fix` unless the user explicitly asks for it — it interferes with agents running in parallel.
|
||||||
|
When running build/typecheck/test commands, pipe through `tail -20` to avoid flooding the context window (e.g. `pnpm typecheck 2>&1 | tail -20`).
|
||||||
|
|
||||||
- `pnpm install` - Install dependencies
|
- `pnpm install` - Install dependencies
|
||||||
- `pnpm dev` - Dev server with hot reload
|
- `pnpm dev` - Dev server with hot reload
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,9 @@ A new approach to task management with AI agents.
|
||||||
|
|
||||||
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
|
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
|
||||||
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
|
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
|
||||||
|
- **Solo mode** — a one-member team: a single agent (regular claude process) that creates its own tasks, leaves comments, and shows live progress on the kanban board — saves tokens compared to a full team and can be expanded to a full team at any time
|
||||||
|
- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
|
||||||
- **Smart task-to-log matching** — automatically links Claude session logs to specific tasks based on status change timestamps, even when a task moves back and forth between states
|
- **Smart task-to-log matching** — automatically links Claude session logs to specific tasks based on status change timestamps, even when a task moves back and forth between states
|
||||||
- **Solo mode** — a one-member team: a single agent that creates its own tasks, leaves comments, and shows live progress on the kanban board — saves tokens compared to a full team and can be expanded to a full team at any time
|
|
||||||
- **Zero-setup onboarding** — built-in Claude Code installation and authentication, ready to go out of the box
|
- **Zero-setup onboarding** — built-in Claude Code installation and authentication, ready to go out of the box
|
||||||
- **Built-in code editor** — edit project files with Git support and other essential features without leaving the app
|
- **Built-in code editor** — edit project files with Git support and other essential features without leaving the app
|
||||||
- **Branch strategy control** — choose via prompt whether all agents work on a single branch or each gets its own git worktree
|
- **Branch strategy control** — choose via prompt whether all agents work on a single branch or each gets its own git worktree
|
||||||
|
|
@ -204,8 +205,11 @@ pnpm dist # macOS + Windows + Linux
|
||||||
|
|
||||||
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||||
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
||||||
- [ ] Context management: control and curate what context each agent sees (files, docs, MCP servers, skills)
|
- [ ] Install skills, MCP, and integrations via an intuitive UI, and only for selected agents
|
||||||
|
- [ ] Planning mode to organize agent plans before execution
|
||||||
|
- [ ] Сurate what context each agent sees (files, docs, MCP servers, skills)
|
||||||
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
|
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
|
||||||
|
- [ ]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
467
docs/research/model-agnostic-proxy.md
Normal file
467
docs/research/model-agnostic-proxy.md
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
# Model-Agnostic Proxy для Claude Code Agent Teams
|
||||||
|
|
||||||
|
Дата исследования: 2026-03-06
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Сделать Claude Code Agent Teams model-agnostic: lead остаётся на Claude, а teammates могут работать на GPT-4o, Gemini, DeepSeek, Kimi K2.5 и других моделях через прокси, который транслирует Anthropic Messages API в формат целевого провайдера.
|
||||||
|
|
||||||
|
## Ключевой механизм
|
||||||
|
|
||||||
|
Claude Code обращается к Anthropic Messages API (`/v1/messages`). Переменная `ANTHROPIC_BASE_URL` позволяет перенаправить запросы на локальный прокси. Прокси:
|
||||||
|
|
||||||
|
1. Принимает запрос в формате Anthropic Messages API
|
||||||
|
2. Транслирует в формат целевого провайдера (OpenAI Chat Completions и др.)
|
||||||
|
3. Пересылает провайдеру
|
||||||
|
4. Получает SSE-стрим ответа
|
||||||
|
5. Транслирует обратно в формат Anthropic SSE-событий
|
||||||
|
6. Отдаёт Claude Code CLI как будто это ответ от Claude
|
||||||
|
|
||||||
|
Team tools (TeamCreate, TaskCreate, SendMessage, TaskGet, TaskList, TaskUpdate, TeamDelete) исполняются **локально Claude Code CLI**. LLM только генерирует `tool_use` блоки. Значит прокси не нужно знать о team-семантике — достаточно корректно транслировать tool_use формат.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Исследованные проекты
|
||||||
|
|
||||||
|
### 1. HydraTeams
|
||||||
|
|
||||||
|
- **URL**: https://github.com/Pickle-Pixel/HydraTeams
|
||||||
|
- **Назначение**: Прокси-переводчик API специально для Agent Teams
|
||||||
|
- **Язык**: TypeScript (~580 строк, 8 файлов)
|
||||||
|
- **Зависимости**: Zero runtime dependencies (только Node.js builtins)
|
||||||
|
- **Лицензия**: Не указана явно
|
||||||
|
- **Stars**: 33 | **Forks**: 9 | **Commits**: 4 | **Создан**: 2026-02-08
|
||||||
|
|
||||||
|
#### Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
index.ts (35) — точка входа, banner, graceful shutdown
|
||||||
|
proxy.ts (280) — HTTP-сервер, маршрутизация, retry
|
||||||
|
config.ts (95) — CLI-аргументы, env vars, Codex JWT
|
||||||
|
logger.ts (190) — логирование, идентификация сессий
|
||||||
|
translators/
|
||||||
|
types.ts (120) — интерфейсы Anthropic + OpenAI
|
||||||
|
messages.ts (85) — конвертация истории сообщений
|
||||||
|
request.ts (65) — Anthropic req -> OpenAI Chat Completions req
|
||||||
|
request-responses.ts (145) — Anthropic req -> ChatGPT Responses API req
|
||||||
|
response.ts (185) — OpenAI SSE -> Anthropic SSE
|
||||||
|
response-responses.ts (235) — Responses API SSE -> Anthropic SSE
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Два конвейера трансляции
|
||||||
|
|
||||||
|
1. **OpenAI Chat Completions API** (`--provider openai`) — для GPT-4o, GPT-4o-mini, o3-mini
|
||||||
|
2. **ChatGPT Responses API** (`--provider chatgpt`) — для ChatGPT Subscription ($0 дополнительных затрат)
|
||||||
|
|
||||||
|
#### Lead vs Teammate детекция
|
||||||
|
|
||||||
|
- Lead: маркер `<!-- hydra:lead -->` в CLAUDE.md (попадает в system prompt)
|
||||||
|
- Teammate: фраза `"the user interacts primarily with the team lead"` в system prompt
|
||||||
|
- Lead-запросы passthrough на настоящий Anthropic API
|
||||||
|
- Teammate-запросы транслируются на целевую модель
|
||||||
|
|
||||||
|
#### Что работает правильно
|
||||||
|
|
||||||
|
- Tool definitions: `input_schema` -> `parameters`, `tool_choice` маппинг корректен
|
||||||
|
- tool_use блоки ассистанта -> `tool_calls` формат OpenAI
|
||||||
|
- tool_result -> `role: "tool"` сообщения
|
||||||
|
- Streaming: StreamState отслеживает blockIndex, activeToolCalls, textBlockStarted
|
||||||
|
- Правильная SSE-последовательность: message_start -> content_block_start -> content_block_delta -> content_block_stop -> message_delta -> message_stop
|
||||||
|
- Retry с exponential backoff на 429 (до 5 попыток)
|
||||||
|
|
||||||
|
#### Найденные баги (code review)
|
||||||
|
|
||||||
|
| # | Баг | Серьёзность |
|
||||||
|
|---|-----|-------------|
|
||||||
|
| 1 | **response-responses.ts**: `content_index` используется вместо Anthropic `blockIndex` в `response.output_text.done` — content_block_stop уйдёт с неправильным index | Высокая |
|
||||||
|
| 2 | **proxy.ts**: `shouldPassthrough()` получает `parsed.system` (может быть массив `AnthropicSystemBlock[]`) как string — `count_tokens` passthrough для lead не сработает | Средняя |
|
||||||
|
| 3 | **proxy.ts**: Non-streaming `JSON.parse(tc.function.arguments)` — если OpenAI вернёт невалидный JSON, exception убьёт весь запрос | Средняя |
|
||||||
|
| 4 | **proxy.ts**: `shouldPassthrough("*")` означает "все Claude модели", а не "всё" — контринтуитивно | Низкая |
|
||||||
|
| 5 | **logger.ts**: Warmup-запросы определяются по `toolCount === 0` — может ложно классифицировать обычные запросы | Низкая |
|
||||||
|
|
||||||
|
#### Критические проблемы
|
||||||
|
|
||||||
|
| Проблема | Серьёзность |
|
||||||
|
|----------|-------------|
|
||||||
|
| 0 тестов | Критично |
|
||||||
|
| Нет таймаутов на upstream — fetch() без AbortController, зависнет навсегда | Критично |
|
||||||
|
| Слушает на 0.0.0.0 — доступен в сети, релеит auth headers | Критично |
|
||||||
|
| Teammate детекция по строке "the user interacts primarily with the team lead" — сломается при обновлении Claude Code | Высокая |
|
||||||
|
| Spoofed model hardcoded `claude-sonnet-4-5-20250929` — устареет | Средняя |
|
||||||
|
| Token counting = `JSON.length / 4` — грубая заглушка | Средняя |
|
||||||
|
| Нет extended thinking — thinking блоки игнорируются | Средняя |
|
||||||
|
| Нет image/multimodal — молча теряются | Низкая |
|
||||||
|
|
||||||
|
#### Безопасность
|
||||||
|
|
||||||
|
- Сервер слушает на 0.0.0.0 (все интерфейсы) — в сети это дыра
|
||||||
|
- Passthrough релеит auth headers (x-api-key, authorization, cookie) к api.anthropic.com
|
||||||
|
- JWT парсинг без валидации подписи
|
||||||
|
- Нет rate limiting на входящие запросы
|
||||||
|
- Логи могут содержать API-ключи в ответах об ошибках
|
||||||
|
|
||||||
|
#### Итоговые оценки
|
||||||
|
|
||||||
|
| Аспект | Оценка |
|
||||||
|
|--------|:------:|
|
||||||
|
| Архитектура | 7/10 |
|
||||||
|
| API трансляция | 6/10 |
|
||||||
|
| Lead/Teammate детекция | 5/10 |
|
||||||
|
| Обработка ошибок | 4/10 |
|
||||||
|
| Streaming | 7/10 |
|
||||||
|
| Безопасность | 3/10 |
|
||||||
|
| Production readiness | 3/10 |
|
||||||
|
| **Общая** | **5/10** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. free-claude-code
|
||||||
|
|
||||||
|
- **URL**: https://github.com/Alishahryar1/free-claude-code
|
||||||
|
- **Назначение**: Прокси для использования Claude Code с бесплатными моделями
|
||||||
|
- **Язык**: Python (FastAPI + uvicorn)
|
||||||
|
- **Stars**: 814 | **Forks**: 95 | **Issues**: 4 | **Создан**: 2026-01-28
|
||||||
|
- **Лицензия**: MIT
|
||||||
|
- **Тесты**: 85+ файлов, pytest, GitHub Actions CI
|
||||||
|
|
||||||
|
#### Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
server.py — точка входа (uvicorn)
|
||||||
|
api/
|
||||||
|
app.py — FastAPI factory + lifespan
|
||||||
|
routes.py — POST /v1/messages, GET /health
|
||||||
|
detection.py — эвристики определения типа запроса
|
||||||
|
optimization_handlers.py — 5 fast-path перехватчиков
|
||||||
|
request_utils.py — подсчёт токенов (tiktoken cl100k_base)
|
||||||
|
models/anthropic.py — Pydantic модели Anthropic request
|
||||||
|
providers/
|
||||||
|
base.py — BaseProvider (ABC)
|
||||||
|
openai_compat.py — OpenAICompatibleProvider (основная логика)
|
||||||
|
common/
|
||||||
|
message_converter.py — Anthropic <-> OpenAI конвертер (~200 строк)
|
||||||
|
sse_builder.py — SSE event builder Anthropic формат (~300 строк)
|
||||||
|
think_parser.py — парсер <think> тегов (~80 строк)
|
||||||
|
heuristic_tool_parser.py — парсер tool calls из текста
|
||||||
|
nvidia_nim/, open_router/, lmstudio/ — конкретные провайдеры
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Провайдеры и модели
|
||||||
|
|
||||||
|
##### NVIDIA NIM (бесплатно, 40 req/min)
|
||||||
|
|
||||||
|
**Tier S (флагманы):**
|
||||||
|
|
||||||
|
| Model ID | Thinking | Tool Calling |
|
||||||
|
|----------|:--------:|:------------:|
|
||||||
|
| `moonshotai/kimi-k2.5` | Да | Native |
|
||||||
|
| `qwen/qwen3-coder-480b-a35b-instruct` | Да | Native |
|
||||||
|
| `z-ai/glm5` | Да | Native |
|
||||||
|
| `deepseek-ai/deepseek-v3.2` | Да | Native |
|
||||||
|
| `mistralai/mistral-large-3-675b-instruct` | Да | Native |
|
||||||
|
| `minimaxai/minimax-m2.5` | Да | Native |
|
||||||
|
|
||||||
|
**Tier A:**
|
||||||
|
|
||||||
|
| Model ID | Thinking | Tool Calling |
|
||||||
|
|----------|:--------:|:------------:|
|
||||||
|
| `z-ai/glm4.7` | Да | Native |
|
||||||
|
| `mistralai/devstral-2-123b-instruct` | Да | Native |
|
||||||
|
| `openai/gpt-oss-120b` | Да | Native |
|
||||||
|
| `meta/llama-3.1-405b-instruct` | Нет | Native |
|
||||||
|
|
||||||
|
**Tier B (быстрые):**
|
||||||
|
|
||||||
|
| Model ID | Thinking | Tool Calling |
|
||||||
|
|----------|:--------:|:------------:|
|
||||||
|
| `qwen/qwen2.5-coder-32b-instruct` | Нет | Native |
|
||||||
|
| `stepfun-ai/step-3.5-flash` | Да | Native |
|
||||||
|
| `meta/llama-3.3-70b-instruct` | Нет | Native |
|
||||||
|
|
||||||
|
Всего в каталоге NIM **185 моделей**, все бесплатные. max_tokens: 81920.
|
||||||
|
|
||||||
|
##### OpenRouter (free модели с суффиксом `:free`)
|
||||||
|
|
||||||
|
**С Tool Calling + Thinking:**
|
||||||
|
|
||||||
|
| Model ID | Context | Max Output |
|
||||||
|
|----------|:-------:|:----------:|
|
||||||
|
| `openai/gpt-oss-120b:free` | 131K | 131K |
|
||||||
|
| `stepfun/step-3.5-flash:free` | 256K | 256K |
|
||||||
|
| `qwen/qwen3-coder:free` | 262K | 262K |
|
||||||
|
| `qwen/qwen3-235b-a22b-thinking-2507:free` | 131K | — |
|
||||||
|
| `z-ai/glm-4.5-air:free` | 131K | 96K |
|
||||||
|
|
||||||
|
**С Tool Calling, без Thinking:**
|
||||||
|
|
||||||
|
| Model ID | Context |
|
||||||
|
|----------|:-------:|
|
||||||
|
| `meta-llama/llama-3.3-70b-instruct:free` | 128K |
|
||||||
|
| `mistralai/mistral-small-3.1-24b-instruct:free` | 128K |
|
||||||
|
| `google/gemma-3-27b-it:free` | 131K |
|
||||||
|
|
||||||
|
Всего **28 бесплатных моделей** + мета-роутер `openrouter/free`.
|
||||||
|
|
||||||
|
##### LM Studio (полностью локально, без лимитов)
|
||||||
|
|
||||||
|
| Модель | VRAM | Качество кода |
|
||||||
|
|--------|:----:|:---:|
|
||||||
|
| `unsloth/MiniMax-M2.5-GGUF` | 48GB+ | 7/10 |
|
||||||
|
| `unsloth/Qwen3-Coder-480B-A35B-Instruct-GGUF` | 48GB+ | 8/10 |
|
||||||
|
| `unsloth/Qwen3.5-35B-A3B-GGUF` | 24GB | 6/10 |
|
||||||
|
| `unsloth/GLM-4.7-Flash-GGUF` | 24GB | 6/10 |
|
||||||
|
| `unsloth/Qwen2.5-Coder-32B-Instruct-GGUF` | 24GB | 6/10 |
|
||||||
|
|
||||||
|
#### Что лучше чем HydraTeams
|
||||||
|
|
||||||
|
| Аспект | free-claude-code | HydraTeams |
|
||||||
|
|--------|:---:|:---:|
|
||||||
|
| Тесты | **85+ файлов**, CI | 0 |
|
||||||
|
| Thinking blocks | **Два пути** (native + `<think>` парсер) | Нет |
|
||||||
|
| Token counting | **tiktoken** (реальный подсчёт) | `JSON.length / 4` |
|
||||||
|
| Provider abstraction | **ABC + наследование** | Hardcoded if/else |
|
||||||
|
| Error handling | **Graceful shutdown**, rate limiter | Базовый try/catch |
|
||||||
|
| Heuristic tool parser | **Есть** (для моделей без native tool use) | Нет |
|
||||||
|
|
||||||
|
#### Проблемы для Agent Teams
|
||||||
|
|
||||||
|
1. **Task tool patching** — принудительно ставит `run_in_background=False` в трёх местах. Опасная мина для team coordination
|
||||||
|
2. **Optimization interceptors** — 5 эвристик могут ложно сработать на teammate messages
|
||||||
|
3. **Общий rate limiter** — singleton `GlobalRateLimiter` (40 req/min, max_concurrency=5). 5 teammates = мгновенный bottleneck. Один 429 блокирует ВСЕХ
|
||||||
|
4. **Python 3.14 requirement** — ещё в beta, проблема для bundling
|
||||||
|
5. **tiktoken** требует Rust-скомпилированный .so — кросс-платформенная сборка сложная
|
||||||
|
6. **Нет lead/teammate разделения** — все запросы на один провайдер
|
||||||
|
|
||||||
|
#### Bundling с Electron
|
||||||
|
|
||||||
|
| Критерий | free-claude-code (Python) | HydraTeams (TypeScript) |
|
||||||
|
|----------|:---:|:---:|
|
||||||
|
| Bundling в Electron | PyInstaller ~100-150MB, Python 3.14, Rust deps | Прямо в main process, 0 deps |
|
||||||
|
| Размер | ~50MB минимум | ~580 строк, КБ |
|
||||||
|
| Кросс-платформа | tiktoken .so для каждой платформы | Нативный Node.js |
|
||||||
|
| Запуск | Child process + Python runtime | Просто import |
|
||||||
|
|
||||||
|
#### Переиспользуемое ядро (~900 строк Python)
|
||||||
|
|
||||||
|
- `message_converter.py` (~200 строк) — полностью независим
|
||||||
|
- `sse_builder.py` (~300 строк) — почти независим (убрать Task patching)
|
||||||
|
- `think_parser.py` (~80 строк) — полностью независим
|
||||||
|
- `openai_compat.py` (~250 строк) — stream_response логика
|
||||||
|
|
||||||
|
#### Итоговые оценки
|
||||||
|
|
||||||
|
| Критерий | Оценка |
|
||||||
|
|----------|:------:|
|
||||||
|
| Качество кода | 7/10 |
|
||||||
|
| Тесты | 8/10 |
|
||||||
|
| Bundling с Electron | 2/10 |
|
||||||
|
| Agent Teams совместимость | 3/10 |
|
||||||
|
| Адаптация под наш стек | 3/10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. LiteLLM Proxy
|
||||||
|
|
||||||
|
- **URL**: https://github.com/BerriAI/litellm
|
||||||
|
- **Лицензия**: MIT (enterprise-фичи проприетарные)
|
||||||
|
- **Stars**: ~38,000 | **Forks**: ~6,200
|
||||||
|
- **Язык**: Python
|
||||||
|
- **Провайдеры**: 100+ (OpenAI, Gemini, Bedrock, Azure, Groq, DeepSeek...)
|
||||||
|
|
||||||
|
#### Как работает с Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL="http://0.0.0.0:4000"
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="$LITELLM_MASTER_KEY"
|
||||||
|
claude --model gpt-4o
|
||||||
|
```
|
||||||
|
|
||||||
|
Нативный Anthropic SDK-совместимый endpoint `/messages`. Полная трансляция tool_use, streaming SSE.
|
||||||
|
|
||||||
|
#### Почему НЕ подходит для Electron
|
||||||
|
|
||||||
|
| Проблема | Детали |
|
||||||
|
|----------|--------|
|
||||||
|
| Python | 70+ зависимостей, Prisma с Node binaries, grpcio |
|
||||||
|
| Размер | ~2 ГБ (Docker-образ) |
|
||||||
|
| RAM | Рекомендация 8 ГБ для production |
|
||||||
|
| Bundling | Никто никогда не бандлил с desktop app |
|
||||||
|
|
||||||
|
#### Известные баги с Claude Code
|
||||||
|
|
||||||
|
- [#21446](https://github.com/BerriAI/litellm/issues/21446) — Gemini не работает через LiteLLM
|
||||||
|
- [#14194](https://github.com/BerriAI/litellm/issues/14194) — Bedrock thinking + tools конфликтуют
|
||||||
|
- [#12222](https://github.com/BerriAI/litellm/issues/12222) — Gemini падает на tools с optional args
|
||||||
|
- [#18730](https://github.com/BerriAI/litellm/issues/18730) — Concurrent requests обходят rate limits
|
||||||
|
|
||||||
|
**Вердикт: enterprise-серверный gateway, не для десктопа. 1/10 для нашего кейса.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Bifrost (Maxim AI)
|
||||||
|
|
||||||
|
- **URL**: https://github.com/maximhq/bifrost
|
||||||
|
- **Лицензия**: Apache 2.0
|
||||||
|
- **Stars**: ~2,700 | **Commits**: 3,341
|
||||||
|
- **Язык**: Go (11 мкс overhead при 5000 RPS)
|
||||||
|
- **Провайдеры**: 20+ (OpenAI, Gemini, Bedrock, Azure, Mistral, Ollama...)
|
||||||
|
|
||||||
|
#### Bundling с Electron
|
||||||
|
|
||||||
|
Bifrost компилируется в единый статический Go-бинарник. npm-пакет `@maximhq/bifrost` скачивает prebuilt binary с CDN под нужную платформу.
|
||||||
|
|
||||||
|
```
|
||||||
|
https://downloads.getmaxim.ai/bifrost/{version}/{platform}/{arch}/bifrost-http
|
||||||
|
```
|
||||||
|
|
||||||
|
Поддерживаемые платформы:
|
||||||
|
- darwin/arm64 (macOS Apple Silicon)
|
||||||
|
- darwin/amd64 (macOS Intel)
|
||||||
|
- linux/amd64, linux/386
|
||||||
|
- windows/amd64, windows/arm64
|
||||||
|
|
||||||
|
Размер: ~30-60 MB. Запуск: `child_process.spawn(bifrostBinaryPath)`.
|
||||||
|
|
||||||
|
**Bundling: 9/10** — скачать бинарник, положить рядом, запустить как child process.
|
||||||
|
|
||||||
|
#### Проблемы для Agent Teams
|
||||||
|
|
||||||
|
| Issue | Описание | Статус |
|
||||||
|
|-------|----------|--------|
|
||||||
|
| [#1164](https://github.com/maximhq/bifrost/issues/1164) | Parallel tool calls через Bedrock не работают | Открыт |
|
||||||
|
| [#1829](https://github.com/maximhq/bifrost/issues/1829) | Streaming tool call deltas мёрджатся | Закрыт |
|
||||||
|
| [#1804](https://github.com/maximhq/bifrost/issues/1804) | Streaming tool calls с агентскими клиентами не работают | Открыт |
|
||||||
|
| [#828](https://github.com/maximhq/bifrost/issues/828) | Goroutine leak при context cancellation | Открыт |
|
||||||
|
| [#1613](https://github.com/maximhq/bifrost/issues/1613) | SSE streaming от Gemini ломается | Открыт |
|
||||||
|
|
||||||
|
Не использует anthropic-go-sdk, дублирует типы вручную (Discussion #1259).
|
||||||
|
|
||||||
|
**Никто не тестировал Bifrost с Agent Teams.**
|
||||||
|
|
||||||
|
#### Итоговые оценки
|
||||||
|
|
||||||
|
| Критерий | Оценка |
|
||||||
|
|----------|:------:|
|
||||||
|
| Bundling с Electron | 9/10 |
|
||||||
|
| Agent Teams (passthrough) | 8/10 |
|
||||||
|
| Agent Teams (трансляция) | 4/10 |
|
||||||
|
| Зрелость | 7/10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сравнение качества моделей vs Claude
|
||||||
|
|
||||||
|
| Модель | Кодинг | vs Sonnet | vs Opus |
|
||||||
|
|--------|:------:|:---------:|:-------:|
|
||||||
|
| Kimi K2.5 (NIM) | 8/10 | ~80% | ~60% |
|
||||||
|
| Qwen3 Coder 480B (NIM) | 8/10 | ~80% | ~60% |
|
||||||
|
| GLM-5 (NIM) | 7/10 | ~70% | ~50% |
|
||||||
|
| GPT-OSS 120B (NIM/OR) | 7/10 | ~70% | ~50% |
|
||||||
|
| GLM-4.7 (NIM) | 7/10 | ~65% | ~45% |
|
||||||
|
| Step 3.5 Flash (OR) | 6/10 | ~55% | ~35% |
|
||||||
|
| Llama 3.3 70B (OR) | 5/10 | ~45% | ~30% |
|
||||||
|
|
||||||
|
Ни одна бесплатная модель не дотягивает до Claude по качеству агентного кодинга.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Юридические аспекты
|
||||||
|
|
||||||
|
### Что разрешено Anthropic
|
||||||
|
|
||||||
|
- `ANTHROPIC_BASE_URL` — **официально поддерживается** для LLM Gateway
|
||||||
|
- Использование прокси с собственными API-ключами других провайдеров — легально
|
||||||
|
- Документация описывает LLM Gateway конфигурацию: endpoint должен реализовывать Anthropic Messages API
|
||||||
|
|
||||||
|
### Что запрещено
|
||||||
|
|
||||||
|
- Использование OAuth-токенов от Claude Free/Pro/Max подписок в сторонних продуктах
|
||||||
|
- Anthropic активно блокирует несанкционированное использование подписочных токенов
|
||||||
|
- Использование Claude для обучения конкурирующих моделей (Section D.4 Commercial Terms)
|
||||||
|
|
||||||
|
### ChatGPT backend API (HydraTeams)
|
||||||
|
|
||||||
|
- `chatgpt.com/backend-api/codex/responses` — недокументированный API
|
||||||
|
- Нарушение ToS ChatGPT при автоматизации через backend API
|
||||||
|
- Может быть заблокирован в любой момент
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Влияние на наш проект (Claude DevTools)
|
||||||
|
|
||||||
|
JSONL формат сессий **не меняется** — Claude Code CLI генерирует одинаковую структуру независимо от backend-модели. TeamCreate, TaskCreate, SendMessage и прочие team tools остаются теми же. Парсинг и chunk building будет работать без изменений.
|
||||||
|
|
||||||
|
Единственное потенциальное отличие — metadata о модели в сообщениях (model field).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендация по реализации
|
||||||
|
|
||||||
|
### Лучший путь: форк HydraTeams + hardening
|
||||||
|
|
||||||
|
HydraTeams — единственный проект заточенный под Agent Teams, на TypeScript (наш стек), zero deps, встраивается в Electron main process.
|
||||||
|
|
||||||
|
**Что нужно починить обязательно:**
|
||||||
|
1. Localhost-only binding (`127.0.0.1`)
|
||||||
|
2. AbortController + таймауты на upstream fetch
|
||||||
|
3. Баги с indices в response-responses.ts
|
||||||
|
4. Тесты на трансляцию (unit-тесты на каждый translator)
|
||||||
|
5. Убрать hardcoded spoofModel -> сделать конфигурируемым
|
||||||
|
|
||||||
|
**Что заимствовать из free-claude-code:**
|
||||||
|
1. ThinkTagParser — переписать на TS (~50 строк)
|
||||||
|
2. Provider abstraction — интерфейс `TranslationProvider`
|
||||||
|
3. Структуру тестов
|
||||||
|
4. Heuristic tool parser для моделей без native tool calling
|
||||||
|
|
||||||
|
**Что переосмыслить:**
|
||||||
|
- Lead/teammate детекция — маркер по строке хрупкий. Мы знаем роли из TeamDataService, можно передавать через env var `HYDRA_ROLE=lead|teammate`
|
||||||
|
|
||||||
|
### Оценка трудозатрат
|
||||||
|
|
||||||
|
~2-3 дня на hardening + тесты. Итого: TypeScript-пакет ~800-1000 строк с тестами, встраиваемый в Electron main process.
|
||||||
|
|
||||||
|
### Альтернативы
|
||||||
|
|
||||||
|
| Вариант | Надёжность | Уверенность |
|
||||||
|
|---------|:----------:|:-----------:|
|
||||||
|
| Форк HydraTeams + hardening | 7/10 | 8/10 |
|
||||||
|
| Свой proxy с нуля на TS (вдохновлённый обоими) | 8/10 | 7/10 |
|
||||||
|
| Bifrost binary + thin TS translator | 5/10 | 6/10 |
|
||||||
|
| LiteLLM как Docker sidecar | 6/10 | 7/10 |
|
||||||
|
| free-claude-code (Python child process) | 3/10 | 8/10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Другие найденные проекты
|
||||||
|
|
||||||
|
| Проект | Описание | Применимость |
|
||||||
|
|--------|----------|:---:|
|
||||||
|
| [1rgs/claude-code-proxy](https://github.com/1rgs/claude-code-proxy) | На базе LiteLLM, BIG/SMALL model маппинг | 5/10 |
|
||||||
|
| [fuergaosi233/claude-code-proxy](https://github.com/fuergaosi233/claude-code-proxy) | Anthropic -> OpenAI конвертер | 4/10 |
|
||||||
|
| [nielspeter/claude-code-proxy](https://github.com/nielspeter/claude-code-proxy) | Легковесный бинарник, OpenRouter | 4/10 |
|
||||||
|
| [9router](https://github.com/decolua/9router) | Smart router с fallback-каскадом | 5/10 |
|
||||||
|
| [claude-code-teams-mcp](https://github.com/cs50victor/claude-code-teams-mcp) | MCP-сервер, реимплементация Agent Teams | 3/10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектурная схема целевого решения
|
||||||
|
|
||||||
|
```
|
||||||
|
[Lead Agent Process]
|
||||||
|
ANTHROPIC_BASE_URL=http://127.0.0.1:{port}
|
||||||
|
HYDRA_ROLE=lead
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Proxy (TypeScript, в Electron main process)]
|
||||||
|
if role=lead --> passthrough к api.anthropic.com
|
||||||
|
if role=teammate --> трансляция к целевому провайдеру
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Teammate 1] --> OpenAI API (GPT-4o)
|
||||||
|
[Teammate 2] --> NVIDIA NIM (Kimi K2.5)
|
||||||
|
[Teammate 3] --> Local (LM Studio)
|
||||||
|
```
|
||||||
|
|
||||||
|
Stream-json протокол (stdin/stdout между lead и teammates) **не затрагивается** — прокси работает на уровне HTTP API запросов к LLM.
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
|
import { buildToolSummary } from '@shared/utils/toolSummary';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -1486,6 +1487,8 @@ export class TeamDataService {
|
||||||
const combined = stripAgentBlocks(textParts.join('\n')).trim();
|
const combined = stripAgentBlocks(textParts.join('\n')).trim();
|
||||||
if (combined.length < MIN_TEXT_LENGTH) continue;
|
if (combined.length < MIN_TEXT_LENGTH) continue;
|
||||||
|
|
||||||
|
const toolSummary = buildToolSummary(content as Record<string, unknown>[]);
|
||||||
|
|
||||||
// Stable messageId: timestamp + text prefix (survives tail-scan range changes)
|
// Stable messageId: timestamp + text prefix (survives tail-scan range changes)
|
||||||
const textPrefix = combined
|
const textPrefix = combined
|
||||||
.slice(0, 50)
|
.slice(0, 50)
|
||||||
|
|
@ -1500,6 +1503,7 @@ export class TeamDataService {
|
||||||
source: 'lead_session',
|
source: 'lead_session',
|
||||||
leadSessionId: config.leadSessionId,
|
leadSessionId: config.leadSessionId,
|
||||||
messageId: `lead-session-${timestamp}-${textPrefix}`,
|
messageId: `lead-session-${timestamp}-${textPrefix}`,
|
||||||
|
toolSummary,
|
||||||
});
|
});
|
||||||
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
|
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { buildToolSummary } from '@shared/utils/toolSummary';
|
||||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
@ -2913,12 +2914,13 @@ export class TeamProvisioningService {
|
||||||
// captureSendMessageToUser() handles it separately.
|
// captureSendMessageToUser() handles it separately.
|
||||||
if (!run.silentUserDmForward && !hasSendMessageToUser) {
|
if (!run.silentUserDmForward && !hasSendMessageToUser) {
|
||||||
const cleanText = stripAgentBlocks(text).trim();
|
const cleanText = stripAgentBlocks(text).trim();
|
||||||
if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) {
|
if (cleanText.length > 0) {
|
||||||
run.leadMsgSeq += 1;
|
run.leadMsgSeq += 1;
|
||||||
const leadName =
|
const leadName =
|
||||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||||
'team-lead';
|
'team-lead';
|
||||||
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
|
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
|
||||||
|
const toolSummary = buildToolSummary(content ?? []);
|
||||||
const leadMsg: InboxMessage = {
|
const leadMsg: InboxMessage = {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
text: cleanText,
|
text: cleanText,
|
||||||
|
|
@ -2927,6 +2929,7 @@ export class TeamProvisioningService {
|
||||||
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
|
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
|
||||||
messageId,
|
messageId,
|
||||||
source: 'lead_process',
|
source: 'lead_process',
|
||||||
|
toolSummary,
|
||||||
};
|
};
|
||||||
this.pushLiveLeadProcessMessage(run.teamName, leadMsg);
|
this.pushLiveLeadProcessMessage(run.teamName, leadMsg);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ export class TeamSentMessagesStore {
|
||||||
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
||||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||||
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||||
|
toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -131,6 +132,22 @@ export const LeadThoughtsGroupRow = ({
|
||||||
// Chronological order for rendering (oldest at top, newest at bottom)
|
// Chronological order for rendering (oldest at top, newest at bottom)
|
||||||
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
|
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
|
||||||
|
|
||||||
|
// Aggregate tool usage across all thoughts in this group
|
||||||
|
const totalToolSummary = useMemo(() => {
|
||||||
|
const merged: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
for (const t of thoughts) {
|
||||||
|
const parsed = parseToolSummary(t.toolSummary);
|
||||||
|
if (!parsed) continue;
|
||||||
|
total += parsed.total;
|
||||||
|
for (const [name, count] of Object.entries(parsed.byName)) {
|
||||||
|
merged[name] = (merged[name] ?? 0) + count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (total === 0) return null;
|
||||||
|
return formatToolSummary({ total, byName: merged });
|
||||||
|
}, [thoughts]);
|
||||||
|
|
||||||
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
|
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
|
||||||
const computeIsLive = useCallback(
|
const computeIsLive = useCallback(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -225,6 +242,11 @@ export const LeadThoughtsGroupRow = ({
|
||||||
? formatTime(oldest.timestamp)
|
? formatTime(oldest.timestamp)
|
||||||
: `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`}
|
: `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`}
|
||||||
</span>
|
</span>
|
||||||
|
{totalToolSummary && (
|
||||||
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{totalToolSummary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable body — fixed height, always visible */}
|
{/* Scrollable body — fixed height, always visible */}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,82 @@
|
||||||
import React from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { Check, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
|
||||||
|
|
||||||
|
/** Anthropic — official "A" lettermark (Simple Icons) */
|
||||||
|
const AnthropicIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||||
|
<path d="M17.304 3.541h-3.672l6.696 16.918H24Zm-10.608 0L0 20.459h3.744l1.37-3.553h7.005l1.369 3.553h3.744L10.536 3.541Zm-.371 10.223 2.291-5.946 2.292 5.946Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** OpenAI — official hexagonal knot logo (Simple Icons) */
|
||||||
|
const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||||
|
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.998 5.998 0 0 0-3.992 2.9 6.042 6.042 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365 2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.612-1.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Google Gemini — official sparkle/star mark (Simple Icons) */
|
||||||
|
const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||||
|
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Local — server rack icon */
|
||||||
|
const LocalIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" className={className}>
|
||||||
|
<rect x="3" y="3" width="18" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<rect x="3" y="14" width="18" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<circle cx="7" cy="6.5" r="1" fill="currentColor" />
|
||||||
|
<circle cx="7" cy="17.5" r="1" fill="currentColor" />
|
||||||
|
<line
|
||||||
|
x1="10.5"
|
||||||
|
y1="6.5"
|
||||||
|
x2="17.5"
|
||||||
|
y2="6.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="10.5"
|
||||||
|
y1="17.5"
|
||||||
|
x2="17.5"
|
||||||
|
y2="17.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Provider definitions ---
|
||||||
|
|
||||||
|
interface ProviderDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.FC<{ className?: string }>;
|
||||||
|
comingSoon: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS: ProviderDef[] = [
|
||||||
|
{ id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false },
|
||||||
|
{ id: 'openai', label: 'OpenAI', icon: OpenAIIcon, comingSoon: true },
|
||||||
|
{ id: 'google', label: 'Google', icon: GoogleIcon, comingSoon: true },
|
||||||
|
{ id: 'local', label: 'Local', icon: LocalIcon, comingSoon: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTIVE_PROVIDER = PROVIDERS[0];
|
||||||
|
|
||||||
|
// --- Model options (Anthropic only for now) ---
|
||||||
|
|
||||||
const MODEL_OPTIONS = [
|
const MODEL_OPTIONS = [
|
||||||
{ value: '', label: 'Default (account setting)' },
|
{ value: '', label: 'Default' },
|
||||||
{ value: 'opus', label: 'Opus 4.6' },
|
{ value: 'opus', label: 'Opus 4.6' },
|
||||||
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
||||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||||
|
|
@ -35,28 +107,132 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
id,
|
id,
|
||||||
}) => (
|
}) => {
|
||||||
<div className="flex items-center gap-2.5">
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
<Label htmlFor={id} className="label-optional shrink-0">
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
Model (optional)
|
|
||||||
</Label>
|
// Close dropdown on click outside
|
||||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
useEffect(() => {
|
||||||
{MODEL_OPTIONS.map((opt) => (
|
if (!dropdownOpen) return;
|
||||||
<button
|
|
||||||
key={opt.value || '__default__'}
|
const handleClickOutside = (event: MouseEvent): void => {
|
||||||
type="button"
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
id={opt.value === value ? id : undefined}
|
setDropdownOpen(false);
|
||||||
className={cn(
|
}
|
||||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
};
|
||||||
value === opt.value
|
|
||||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
)}
|
}, [dropdownOpen]);
|
||||||
onClick={() => onValueChange(opt.value)}
|
|
||||||
>
|
const ProviderIcon = ACTIVE_PROVIDER.icon;
|
||||||
{opt.label}
|
|
||||||
</button>
|
return (
|
||||||
))}
|
<div className="mb-5">
|
||||||
|
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
||||||
|
Model (optional)
|
||||||
|
</Label>
|
||||||
|
<div ref={containerRef} className="relative inline-block">
|
||||||
|
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||||
|
{/* Provider button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-[3px] px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
|
'mr-0.5 border-r border-[var(--color-border)] pr-2.5',
|
||||||
|
dropdownOpen
|
||||||
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||||
|
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
|
||||||
|
)}
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
>
|
||||||
|
<ProviderIcon className="size-3.5" />
|
||||||
|
<span>{ACTIVE_PROVIDER.label}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'size-3 transition-transform duration-200',
|
||||||
|
dropdownOpen && 'rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Model pills */}
|
||||||
|
{MODEL_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value || '__default__'}
|
||||||
|
type="button"
|
||||||
|
id={opt.value === value ? id : undefined}
|
||||||
|
className={cn(
|
||||||
|
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
value === opt.value
|
||||||
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
)}
|
||||||
|
onClick={() => onValueChange(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider dropdown */}
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full left-0 z-50 mb-1 min-w-[220px] overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
borderColor: 'var(--color-border-subtle)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PROVIDERS.map((provider, index) => {
|
||||||
|
const Icon = provider.icon;
|
||||||
|
const isActive = provider.id === ACTIVE_PROVIDER.id;
|
||||||
|
const isFirst = index === 0;
|
||||||
|
const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={provider.id}>
|
||||||
|
{prevWasActive && !isFirst && (
|
||||||
|
<div
|
||||||
|
className="mx-2 my-1 border-t"
|
||||||
|
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={provider.comingSoon}
|
||||||
|
onClick={() => {
|
||||||
|
if (!provider.comingSoon) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100',
|
||||||
|
isActive && 'bg-indigo-500/10 text-indigo-400',
|
||||||
|
provider.comingSoon && 'cursor-not-allowed opacity-40',
|
||||||
|
!isActive && !provider.comingSoon && 'hover:bg-white/5'
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
!isActive && !provider.comingSoon
|
||||||
|
? { color: 'var(--color-text-secondary)' }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5 shrink-0" />
|
||||||
|
<span className="flex-1">{provider.label}</span>
|
||||||
|
{provider.comingSoon && (
|
||||||
|
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
Coming Soon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isActive && <Check className="size-3.5 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,8 @@ export interface InboxMessage {
|
||||||
attachments?: AttachmentMeta[];
|
attachments?: AttachmentMeta[];
|
||||||
/** Lead session ID that produced this message (for session boundary detection). */
|
/** Lead session ID that produced this message (for session boundary detection). */
|
||||||
leadSessionId?: string;
|
leadSessionId?: string;
|
||||||
|
/** Tool usage summary from assistant message, e.g. "3 tools (2 Read, Bash)" */
|
||||||
|
toolSummary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageRequest {
|
export interface SendMessageRequest {
|
||||||
|
|
|
||||||
47
src/shared/utils/toolSummary.ts
Normal file
47
src/shared/utils/toolSummary.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
export interface ToolSummaryData {
|
||||||
|
total: number;
|
||||||
|
byName: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildToolSummary(content: Record<string, unknown>[]): string | undefined {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const block of content) {
|
||||||
|
if (
|
||||||
|
block &&
|
||||||
|
typeof block === 'object' &&
|
||||||
|
block.type === 'tool_use' &&
|
||||||
|
typeof block.name === 'string'
|
||||||
|
) {
|
||||||
|
counts.set(block.name, (counts.get(block.name) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = Array.from(counts.values()).reduce((a, b) => a + b, 0);
|
||||||
|
if (total === 0) return undefined;
|
||||||
|
const parts = Array.from(counts.entries())
|
||||||
|
.map(([name, count]) => (count === 1 ? name : `${count} ${name}`))
|
||||||
|
.join(', ');
|
||||||
|
return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseToolSummary(summary: string | undefined): ToolSummaryData | null {
|
||||||
|
if (!summary) return null;
|
||||||
|
const match = summary.match(/^(\d+)\s+tools?\s+\(([^)]+)\)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const byName: Record<string, number> = {};
|
||||||
|
for (const part of match[2].split(', ')) {
|
||||||
|
const m = part.match(/^(\d+)\s+(.+)$/);
|
||||||
|
if (m) {
|
||||||
|
byName[m[2]] = parseInt(m[1], 10);
|
||||||
|
} else {
|
||||||
|
byName[part] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { total: parseInt(match[1], 10), byName };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolSummary(data: ToolSummaryData): string {
|
||||||
|
const parts = Object.entries(data.byName)
|
||||||
|
.map(([name, count]) => (count === 1 ? name : `${count} ${name}`))
|
||||||
|
.join(', ');
|
||||||
|
return `${data.total} ${data.total === 1 ? 'tool' : 'tools'} (${parts})`;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue