fix(streaming): use per-message stream ids and oauth resolver
This commit is contained in:
commit
37ec8ff5e0
37 changed files with 1603 additions and 393 deletions
57
README.md
57
README.md
|
|
@ -95,6 +95,63 @@ Optional overrides:
|
|||
- `SMC_CREDENTIALS_PATH` — custom path for `credentials.json5`
|
||||
- `SMC_SKILLS_ENV_PATH` — custom path for `skills.env.json5`
|
||||
|
||||
### LLM Providers
|
||||
|
||||
Super Multica supports multiple LLM providers with two authentication methods:
|
||||
|
||||
**OAuth Providers** (use external CLI login):
|
||||
- `claude-code` — Claude Code OAuth (requires `claude login`)
|
||||
- `openai-codex` — OpenAI Codex OAuth (requires `codex login`)
|
||||
|
||||
**API Key Providers** (configure in `credentials.json5`):
|
||||
- `anthropic`, `openai`, `kimi-coding`, `google`, `groq`, `mistral`, `xai`, `openrouter`
|
||||
|
||||
#### Check Provider Status
|
||||
|
||||
```bash
|
||||
# In interactive mode
|
||||
/provider
|
||||
|
||||
# Output shows all providers with status
|
||||
🔌 Provider Status
|
||||
|
||||
Current: kimi-coding
|
||||
|
||||
Available Providers:
|
||||
ID Name Auth Status
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
✓ claude-code Claude Code (OAuth) OAuth ready
|
||||
✗ openai-codex Codex (OAuth) OAuth not logged in
|
||||
✓ kimi-coding Kimi Code API Key configured (current)
|
||||
...
|
||||
```
|
||||
|
||||
#### Using OAuth Providers
|
||||
|
||||
```bash
|
||||
# 1. Install and login to Claude Code
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
claude login
|
||||
|
||||
# 2. Start multica with claude-code provider
|
||||
multica --provider claude-code
|
||||
```
|
||||
|
||||
#### Using API Key Providers
|
||||
|
||||
Add your API key to `~/.super-multica/credentials.json5`:
|
||||
|
||||
```json5
|
||||
{
|
||||
llm: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: { apiKey: "sk-xxx" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Priority
|
||||
|
||||
Each setting is resolved in order (first match wins):
|
||||
|
|
|
|||
2
apps/desktop/.gitignore
vendored
2
apps/desktop/.gitignore
vendored
|
|
@ -9,7 +9,9 @@ lerna-debug.log*
|
|||
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
release
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
|
|
|||
|
|
@ -1,30 +1,22 @@
|
|||
# React + TypeScript + Vite
|
||||
# @multica/desktop
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
Electron desktop app. Vite + React + `createHashRouter`.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## Development
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```bash
|
||||
multica dev desktop
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm --filter @multica/desktop build
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Routing**: `react-router-dom` v7 with `createHashRouter` (Electron loads via `file://`, BrowserRouter won't work). Pages go in `src/pages/`.
|
||||
- **UI**: All components from `@multica/ui`. No local UI components.
|
||||
- **State**: Store hooks from `@multica/store`.
|
||||
- **Styles**: Tailwind CSS v4 via `@multica/ui/globals.css`, imported in `src/main.tsx`.
|
||||
|
|
|
|||
|
|
@ -4,18 +4,8 @@ import path from 'node:path'
|
|||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// The built directory structure
|
||||
//
|
||||
// ├─┬─┬ dist
|
||||
// │ │ └── index.html
|
||||
// │ │
|
||||
// │ ├─┬ dist-electron
|
||||
// │ │ ├── main.js
|
||||
// │ │ └── preload.mjs
|
||||
// │
|
||||
process.env.APP_ROOT = path.join(__dirname, '..')
|
||||
|
||||
// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
|
||||
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
|
||||
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
|
||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
|
||||
|
|
@ -26,17 +16,11 @@ let win: BrowserWindow | null
|
|||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
},
|
||||
})
|
||||
|
||||
// Test active push message to Renderer-process.
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
win?.webContents.send('main-process-message', (new Date).toLocaleString())
|
||||
})
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL)
|
||||
} else {
|
||||
|
|
@ -45,9 +29,6 @@ function createWindow() {
|
|||
}
|
||||
}
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
|
|
@ -56,8 +37,6 @@ app.on('window-all-closed', () => {
|
|||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>Multica</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
"dependencies": {
|
||||
"@multica/ui": "workspace:*",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "^7.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
|
||||
<g id="not-lightning" clip-path="url(#clip0_103_2)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from="0 64 64"
|
||||
to="360 64 64"
|
||||
dur="20s"
|
||||
repeatCount="indefinite"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
</g>
|
||||
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_103_2">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
|
|
@ -1,26 +0,0 @@
|
|||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_103_2)">
|
||||
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_103_2">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,7 +1,12 @@
|
|||
import { ComponentExample } from '@multica/ui/components/component-example'
|
||||
import { createHashRouter, RouterProvider } from 'react-router-dom'
|
||||
import HomePage from './pages/home'
|
||||
import ChatPage from './pages/chat'
|
||||
|
||||
function App() {
|
||||
return <ComponentExample />
|
||||
const router = createHashRouter([
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '/chat', element: <ChatPage /> },
|
||||
])
|
||||
|
||||
export default function App() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4 KiB |
|
|
@ -8,8 +8,3 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
// Use contextBridge
|
||||
window.ipcRenderer.on('main-process-message', (_event, message) => {
|
||||
console.log(message)
|
||||
})
|
||||
|
|
|
|||
15
apps/desktop/src/pages/chat.tsx
Normal file
15
apps/desktop/src/pages/chat.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
|
||||
export default function ChatPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-2xl font-bold">Chat</h1>
|
||||
<Button variant="outline" onClick={() => navigate('/')}>
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
apps/desktop/src/pages/home.tsx
Normal file
12
apps/desktop/src/pages/home.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Button onClick={() => navigate('/chat')}>Open Chat</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -27,4 +27,4 @@ export {
|
|||
type UpdateGatewayResult,
|
||||
} from "./rpc";
|
||||
|
||||
export { StreamAction, type StreamPayload } from "./stream";
|
||||
export { StreamAction, type StreamState, type StreamPayload } from "./stream";
|
||||
|
|
|
|||
|
|
@ -2,10 +2,19 @@
|
|||
|
||||
export const StreamAction = "stream" as const;
|
||||
|
||||
/** 流消息状态 */
|
||||
export type StreamState = "delta" | "final" | "error";
|
||||
|
||||
/** 流消息 payload */
|
||||
export interface StreamPayload<T = unknown> {
|
||||
/** 流 ID,用于关联同一个流的所有消息 */
|
||||
export interface StreamPayload {
|
||||
/** 流 ID(即 messageId),关联同一个流的所有消息 */
|
||||
streamId: string;
|
||||
/** 数据 */
|
||||
data: T;
|
||||
/** 所属 agent ID */
|
||||
agentId: string;
|
||||
/** 流状态 */
|
||||
state: StreamState;
|
||||
/** 累计文本内容(delta/final 时) */
|
||||
content?: string;
|
||||
/** 错误信息(error 时) */
|
||||
error?: string;
|
||||
}
|
||||
|
|
|
|||
17
packages/store/README.md
Normal file
17
packages/store/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# @multica/store
|
||||
|
||||
Zustand state management for Multica apps.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
// From barrel
|
||||
import { useHubStore, useMessagesStore, useGatewayStore } from '@multica/store'
|
||||
|
||||
// Per-file subpath import
|
||||
import { useGatewayStore } from '@multica/store/gateway'
|
||||
import { useHubStore } from '@multica/store/hub'
|
||||
import { useMessagesStore } from '@multica/store/messages'
|
||||
import { useHubInit } from '@multica/store/hub-init'
|
||||
import { useDeviceId } from '@multica/store/device-id'
|
||||
```
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { create } from "zustand"
|
||||
import { GatewayClient, type ConnectionState, type DeviceInfo, type SendErrorResponse } from "@multica/sdk"
|
||||
import { GatewayClient, StreamAction, type ConnectionState, type DeviceInfo, type SendErrorResponse, type StreamPayload } from "@multica/sdk"
|
||||
import { useMessagesStore } from "./messages"
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "http://localhost:3000"
|
||||
|
|
@ -45,6 +45,32 @@ export const useGatewayStore = create<GatewayStore>()((set, get) => ({
|
|||
})
|
||||
.onStateChange((connectionState) => set({ connectionState }))
|
||||
.onMessage((msg) => {
|
||||
// Handle streaming messages
|
||||
if (msg.action === StreamAction) {
|
||||
const payload = msg.payload as StreamPayload
|
||||
const store = useMessagesStore.getState()
|
||||
switch (payload.state) {
|
||||
case "delta": {
|
||||
const exists = store.messages.some((m) => m.id === payload.streamId)
|
||||
if (!exists) {
|
||||
store.startStream(payload.streamId, payload.agentId)
|
||||
}
|
||||
if (payload.content) {
|
||||
store.appendStream(payload.streamId, payload.content)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "final":
|
||||
store.endStream(payload.streamId, payload.content ?? "")
|
||||
break
|
||||
case "error":
|
||||
store.endStream(payload.streamId, `[error] ${payload.error}`)
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: complete message handling
|
||||
const payload = msg.payload as { agentId?: string; content?: string }
|
||||
if (payload?.agentId && payload?.content) {
|
||||
useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface Message {
|
|||
|
||||
interface MessagesState {
|
||||
messages: Message[]
|
||||
streamingIds: Set<string>
|
||||
}
|
||||
|
||||
interface MessagesActions {
|
||||
|
|
@ -18,12 +19,16 @@ interface MessagesActions {
|
|||
updateMessage: (id: string, content: string) => void
|
||||
loadMessages: (agentId: string, msgs: Message[]) => void
|
||||
clearMessages: (agentId?: string) => void
|
||||
startStream: (streamId: string, agentId: string) => void
|
||||
appendStream: (streamId: string, content: string) => void
|
||||
endStream: (streamId: string, content: string) => void
|
||||
}
|
||||
|
||||
export type MessagesStore = MessagesState & MessagesActions
|
||||
|
||||
export const useMessagesStore = create<MessagesStore>()((set, get) => ({
|
||||
messages: [],
|
||||
streamingIds: new Set<string>(),
|
||||
|
||||
addUserMessage: (content, agentId) => {
|
||||
set((s) => ({
|
||||
|
|
@ -54,4 +59,32 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
|
|||
messages: agentId ? s.messages.filter((m) => m.agentId !== agentId) : [],
|
||||
}))
|
||||
},
|
||||
|
||||
startStream: (streamId, agentId) => {
|
||||
set((s) => {
|
||||
const ids = new Set(s.streamingIds)
|
||||
ids.add(streamId)
|
||||
return {
|
||||
messages: [...s.messages, { id: streamId, role: "assistant" as const, content: "", agentId }],
|
||||
streamingIds: ids,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
appendStream: (streamId, content) => {
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)),
|
||||
}))
|
||||
},
|
||||
|
||||
endStream: (streamId, content) => {
|
||||
set((s) => {
|
||||
const ids = new Set(s.streamingIds)
|
||||
ids.delete(streamId)
|
||||
return {
|
||||
messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)),
|
||||
streamingIds: ids,
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
32
packages/ui/README.md
Normal file
32
packages/ui/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# @multica/ui
|
||||
|
||||
Shared UI component library. Shadcn + Tailwind CSS v4.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
// UI components — subpath imports, no barrel
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { Card, CardContent } from '@multica/ui/components/ui/card'
|
||||
|
||||
// Feature components
|
||||
import { ThemeProvider } from '@multica/ui/components/theme-provider'
|
||||
import { Chat } from '@multica/ui/components/chat'
|
||||
import { Markdown } from '@multica/ui/components/markdown'
|
||||
|
||||
// Hooks
|
||||
import { useIsMobile } from '@multica/ui/hooks/use-mobile'
|
||||
import { useAutoScroll } from '@multica/ui/hooks/use-auto-scroll'
|
||||
|
||||
// Utilities
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
|
||||
// Styles (app entry point)
|
||||
import '@multica/ui/globals.css'
|
||||
```
|
||||
|
||||
## Adding Components
|
||||
|
||||
```bash
|
||||
pnpm --filter @multica/ui dlx shadcn@latest add <component>
|
||||
```
|
||||
|
|
@ -6,6 +6,7 @@ import { Badge } from "@multica/ui/components/ui/badge";
|
|||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ChatInput } from "@multica/ui/components/chat-input";
|
||||
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
|
||||
import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
|
||||
import { toast } from "@multica/ui/components/ui/sonner";
|
||||
|
|
@ -27,6 +28,7 @@ export function Chat() {
|
|||
const gwState = useGatewayStore((s) => s.connectionState)
|
||||
|
||||
const messages = useMessagesStore((s) => s.messages)
|
||||
const streamingIds = useMessagesStore((s) => s.streamingIds)
|
||||
const filtered = useMemo(() => messages.filter(m => m.agentId === activeAgentId), [messages, activeAgentId])
|
||||
|
||||
const handleSend = useCallback((text: string) => {
|
||||
|
|
@ -99,25 +101,32 @@ export function Chat() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-6 space-y-6 max-w-4xl mx-auto">
|
||||
{filtered.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"flex",
|
||||
msg.role === "user" ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
{filtered.map((msg) => {
|
||||
const isStreaming = streamingIds.has(msg.id)
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] p-1 px-2.5" : "w-full p-1 px-2.5"
|
||||
"flex",
|
||||
msg.role === "user" ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<MemoizedMarkdown mode="minimal" id={msg.id}>
|
||||
{msg.content}
|
||||
</MemoizedMarkdown>
|
||||
<div
|
||||
className={cn(
|
||||
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] p-1 px-2.5" : "w-full p-1 px-2.5"
|
||||
)}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<StreamingMarkdown content={msg.content} isStreaming={true} mode="minimal" />
|
||||
) : (
|
||||
<MemoizedMarkdown mode="minimal" id={msg.id}>
|
||||
{msg.content}
|
||||
</MemoizedMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
45
pnpm-lock.yaml
generated
45
pnpm-lock.yaml
generated
|
|
@ -156,6 +156,9 @@ importers:
|
|||
react-dom:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3(react@19.2.3)
|
||||
react-router-dom:
|
||||
specifier: ^7.13.0
|
||||
version: 7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
|
|
@ -5282,6 +5285,23 @@ packages:
|
|||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react-router-dom@7.13.0:
|
||||
resolution: {integrity: sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.13.0:
|
||||
resolution: {integrity: sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react@19.2.3:
|
||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -5485,6 +5505,9 @@ packages:
|
|||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -10008,7 +10031,7 @@ snapshots:
|
|||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
||||
|
|
@ -10041,7 +10064,7 @@ snapshots:
|
|||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -10056,7 +10079,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
|
|
@ -12368,6 +12391,20 @@ snapshots:
|
|||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-router-dom@7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-router: 7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
||||
react-router@7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
react: 19.2.3
|
||||
set-cookie-parser: 2.7.2
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
react@19.2.3: {}
|
||||
|
||||
read-config-file@6.3.2:
|
||||
|
|
@ -12663,6 +12700,8 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ import { Agent } from "../../runner.js";
|
|||
import type { AgentOptions } from "../../types.js";
|
||||
import { SkillManager } from "../../skills/index.js";
|
||||
import { autocompleteInput, type AutocompleteOption } from "../autocomplete.js";
|
||||
import { colors, dim, cyan, brightCyan, yellow, green, gray } from "../colors.js";
|
||||
import { colors, dim, cyan, brightCyan, yellow, green, gray, red } from "../colors.js";
|
||||
import {
|
||||
getProviderList,
|
||||
getCurrentProvider,
|
||||
getLoginInstructions,
|
||||
getProviderMeta,
|
||||
type ProviderInfo,
|
||||
} from "../../providers/index.js";
|
||||
|
||||
type ChatOptions = {
|
||||
profile?: string;
|
||||
|
|
@ -31,6 +38,8 @@ const COMMANDS = {
|
|||
session: "Show current session ID",
|
||||
new: "Start a new session",
|
||||
multiline: "Toggle multi-line input mode (end with a line containing only '.')",
|
||||
provider: "Show current provider and available options",
|
||||
model: "Show or switch model (usage: /model [model-name])",
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
|
|
@ -455,6 +464,14 @@ class InteractiveCLI {
|
|||
}
|
||||
return true;
|
||||
|
||||
case "provider":
|
||||
this.showProviderStatus();
|
||||
return true;
|
||||
|
||||
case "model":
|
||||
this.handleModelCommand(input);
|
||||
return true;
|
||||
|
||||
default:
|
||||
const invocation = this.skillManager.resolveCommand(input);
|
||||
if (invocation) {
|
||||
|
|
@ -468,6 +485,126 @@ class InteractiveCLI {
|
|||
}
|
||||
}
|
||||
|
||||
private handleModelCommand(input: string) {
|
||||
const parts = input.trim().split(/\s+/);
|
||||
const modelArg = parts.slice(1).join(" ").trim();
|
||||
const currentProvider = this.opts.provider ?? getCurrentProvider();
|
||||
const providerMeta = getProviderMeta(currentProvider);
|
||||
|
||||
if (!providerMeta) {
|
||||
console.log(`${red("Error:")} Unknown provider: ${currentProvider}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// No argument - show current model and available models
|
||||
if (!modelArg) {
|
||||
console.log(`\n${cyan("🎯 Model Status")}\n`);
|
||||
console.log(`${dim("Provider:")} ${green(currentProvider)}`);
|
||||
console.log(`${dim("Current model:")} ${yellow(this.opts.model ?? providerMeta.defaultModel)}`);
|
||||
console.log(`${dim("Default model:")} ${gray(providerMeta.defaultModel)}`);
|
||||
|
||||
console.log(`\n${dim("Available models for")} ${green(currentProvider)}${dim(":")}`);
|
||||
for (const model of providerMeta.models) {
|
||||
const isCurrent = model === (this.opts.model ?? providerMeta.defaultModel);
|
||||
const marker = isCurrent ? yellow(" (current)") : "";
|
||||
const modelDisplay = isCurrent ? yellow(model) : model;
|
||||
console.log(` • ${modelDisplay}${marker}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Switch model:")} ${yellow(`/model <model-name>`)}`);
|
||||
console.log(`${dim("Example:")} ${yellow(`/model ${providerMeta.models[0]}`)}`);
|
||||
console.log("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if model is valid for current provider
|
||||
const normalizedModel = modelArg.toLowerCase();
|
||||
const matchedModel = providerMeta.models.find(
|
||||
(m) => m.toLowerCase() === normalizedModel
|
||||
);
|
||||
|
||||
if (!matchedModel) {
|
||||
console.log(`${red("Error:")} Model "${modelArg}" is not available for provider "${currentProvider}".`);
|
||||
console.log(`\n${dim("Available models:")}`);
|
||||
for (const model of providerMeta.models) {
|
||||
console.log(` • ${model}`);
|
||||
}
|
||||
console.log("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch model
|
||||
const oldModel = this.opts.model ?? providerMeta.defaultModel;
|
||||
this.opts.model = matchedModel;
|
||||
|
||||
// Recreate agent with new model
|
||||
this.agent = this.createAgent(this.agent.sessionId);
|
||||
this.updateStatusBar();
|
||||
|
||||
console.log(`${green("✓")} Model switched: ${gray(oldModel)} → ${yellow(matchedModel)}`);
|
||||
console.log(`${dim("Session preserved:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
|
||||
}
|
||||
|
||||
private showProviderStatus() {
|
||||
const providers = getProviderList();
|
||||
const currentProvider = this.opts.provider ?? getCurrentProvider();
|
||||
|
||||
console.log(`\n${cyan("🔌 Provider Status")}\n`);
|
||||
console.log(`${dim("Current:")} ${green(currentProvider)}`);
|
||||
if (this.opts.model) {
|
||||
console.log(`${dim("Model:")} ${yellow(this.opts.model)}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Available Providers:")}`);
|
||||
console.log(` ${dim("ID".padEnd(16))} ${dim("Name".padEnd(20))} ${dim("Auth".padEnd(12))} ${dim("Status")}`);
|
||||
console.log(` ${dim("─".repeat(70))}`);
|
||||
|
||||
// Group by auth method
|
||||
const apiKeyProviders = providers.filter(p => p.authMethod === "api-key");
|
||||
const oauthProviders = providers.filter(p => p.authMethod === "oauth");
|
||||
|
||||
// OAuth providers first (more interesting)
|
||||
for (const p of oauthProviders) {
|
||||
const status = p.available ? green("✓") : red("✗");
|
||||
const isCurrent = p.id === currentProvider || (p.id === "claude-code" && currentProvider === "anthropic" && p.available);
|
||||
const current = isCurrent ? yellow(" (current)") : "";
|
||||
const idDisplay = isCurrent ? yellow(p.id.padEnd(16)) : p.id.padEnd(16);
|
||||
const authLabel = cyan("OAuth");
|
||||
const statusLabel = p.available ? green("ready") : dim("not logged in");
|
||||
console.log(` ${status} ${idDisplay} ${p.name.padEnd(20)} ${authLabel.padEnd(12)} ${statusLabel}${current}`);
|
||||
}
|
||||
|
||||
// API Key providers
|
||||
for (const p of apiKeyProviders) {
|
||||
const status = p.available ? green("✓") : red("✗");
|
||||
const isCurrent = p.id === currentProvider;
|
||||
const current = isCurrent ? yellow(" (current)") : "";
|
||||
const idDisplay = isCurrent ? yellow(p.id.padEnd(16)) : p.id.padEnd(16);
|
||||
const authLabel = dim("API Key");
|
||||
const statusLabel = p.available ? green("configured") : dim("not configured");
|
||||
console.log(` ${status} ${idDisplay} ${p.name.padEnd(20)} ${authLabel.padEnd(12)} ${statusLabel}${current}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Usage:")}`);
|
||||
console.log(` ${yellow("multica --provider <id>")} ${dim("Start chat with specific provider")}`);
|
||||
console.log(` ${yellow("multica --provider <id> --model <model>")} ${dim("Specify model too")}`);
|
||||
|
||||
console.log(`\n${dim("Examples:")}`);
|
||||
console.log(` ${yellow("multica --provider claude-code")} ${dim("Use Claude Code OAuth")}`);
|
||||
console.log(` ${yellow("multica --provider openai")} ${dim("Use OpenAI with API Key")}`);
|
||||
|
||||
// If user hasn't logged into Claude Code, show instructions
|
||||
const claudeCode = providers.find(p => p.id === "claude-code");
|
||||
if (claudeCode && !claudeCode.available) {
|
||||
console.log(`\n${cyan("💡 Tip:")} To use Claude Code (free with Claude subscription):`);
|
||||
console.log(` 1. Install: ${yellow("npm install -g @anthropic-ai/claude-code")}`);
|
||||
console.log(` 2. Login: ${yellow("claude login")}`);
|
||||
console.log(` 3. Use: ${yellow("multica --provider claude-code")}`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
}
|
||||
|
||||
private async handleInput(input: string) {
|
||||
try {
|
||||
console.log("");
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ describe("output", () => {
|
|||
expect(extractResultDetails(result)).toEqual(result);
|
||||
});
|
||||
|
||||
it("should prefer details when present", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "not json" }],
|
||||
details: { count: 3, truncated: false },
|
||||
};
|
||||
expect(extractResultDetails(result)).toEqual({ count: 3, truncated: false });
|
||||
});
|
||||
|
||||
it("should return direct object if no content array", () => {
|
||||
const result = { count: 10, truncated: true };
|
||||
expect(extractResultDetails(result)).toEqual({ count: 10, truncated: true });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { colors, createSpinner } from "./colors.js";
|
||||
import { extractText } from "../extract-text.js";
|
||||
|
||||
export type AgentOutputState = {
|
||||
lastAssistantText: string;
|
||||
|
|
@ -12,16 +13,6 @@ export type AgentOutput = {
|
|||
handleEvent: (event: AgentEvent) => void;
|
||||
};
|
||||
|
||||
function extractText(message: AgentMessage | undefined): string {
|
||||
if (!message || typeof message !== "object" || !("content" in message)) return "";
|
||||
const content = (message as { content?: Array<{ type: string; text?: string }> }).content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||
}
|
||||
|
|
@ -118,6 +109,11 @@ export function extractResultDetails(result: unknown): Record<string, unknown> |
|
|||
}
|
||||
}
|
||||
|
||||
const withDetails = result as { details?: unknown };
|
||||
if (withDetails.details && typeof withDetails.details === "object") {
|
||||
return withDetails.details as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Try direct object access
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -252,8 +248,18 @@ export function createAgentOutput(params: {
|
|||
}
|
||||
case "tool_execution_end": {
|
||||
// Stop spinner and show final result with summary
|
||||
if (event.isError) {
|
||||
const errorText = extractText(event.result) || "Tool failed";
|
||||
const details = extractResultDetails(event.result);
|
||||
const errorField = details?.error;
|
||||
const hasError =
|
||||
event.isError ||
|
||||
Boolean(errorField) ||
|
||||
details?.success === false;
|
||||
if (hasError) {
|
||||
const errorText =
|
||||
(typeof details?.message === "string" && details.message) ||
|
||||
(typeof errorField === "string" && errorField) ||
|
||||
extractText(event.result) ||
|
||||
"Tool failed";
|
||||
const bullet = colors.toolError("✗");
|
||||
const title = colors.toolName(toolDisplayName(event.toolName));
|
||||
spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`);
|
||||
|
|
|
|||
12
src/agent/extract-text.ts
Normal file
12
src/agent/extract-text.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
/** Extract plain text content from an AgentMessage */
|
||||
export function extractText(message: AgentMessage | undefined): string {
|
||||
if (!message || typeof message !== "object" || !("content" in message)) return "";
|
||||
const content = (message as { content?: Array<{ type: string; text?: string }> }).content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
34
src/agent/providers/index.ts
Normal file
34
src/agent/providers/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Provider Management
|
||||
*
|
||||
* Unified exports for LLM provider management:
|
||||
* - Registry: Provider metadata, status checking, listing
|
||||
* - Resolver: API key resolution, model resolution
|
||||
*/
|
||||
|
||||
// Registry exports
|
||||
export {
|
||||
type AuthMethod,
|
||||
type ProviderInfo,
|
||||
type ProviderMeta,
|
||||
PROVIDER_ALIAS,
|
||||
isOAuthProvider,
|
||||
isProviderAvailable,
|
||||
getCurrentProvider,
|
||||
getProviderMeta,
|
||||
getDefaultModel,
|
||||
getProviderList,
|
||||
getAvailableProviders,
|
||||
formatProviderStatus,
|
||||
getLoginInstructions,
|
||||
} from "./registry.js";
|
||||
|
||||
// Resolver exports
|
||||
export {
|
||||
type ProviderConfig,
|
||||
resolveProviderConfig,
|
||||
resolveApiKey,
|
||||
resolveBaseUrl,
|
||||
resolveModelId,
|
||||
resolveModel,
|
||||
} from "./resolver.js";
|
||||
363
src/agent/providers/oauth/cli-credentials.ts
Normal file
363
src/agent/providers/oauth/cli-credentials.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
/**
|
||||
* CLI Credentials Reader
|
||||
*
|
||||
* Read OAuth credentials from external CLI tools:
|
||||
* - Claude Code: ~/.claude/.credentials.json or macOS Keychain
|
||||
* - Codex: ~/.codex/auth.json or macOS Keychain
|
||||
*
|
||||
* Based on OpenClaw's implementation.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export type OAuthCredential = {
|
||||
type: "oauth";
|
||||
provider: string;
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
export type TokenCredential = {
|
||||
type: "token";
|
||||
provider: string;
|
||||
token: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
export type ClaudeCliCredential = (OAuthCredential | TokenCredential) & {
|
||||
provider: "anthropic";
|
||||
};
|
||||
|
||||
export type CodexCliCredential = OAuthCredential & {
|
||||
provider: "openai-codex";
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Paths
|
||||
// ============================================================
|
||||
|
||||
const CLAUDE_CLI_CREDENTIALS_PATH = ".claude/.credentials.json";
|
||||
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
||||
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
|
||||
|
||||
const CODEX_CLI_AUTH_FILENAME = "auth.json";
|
||||
const CODEX_CLI_KEYCHAIN_SERVICE = "Codex Auth";
|
||||
|
||||
function resolveHomePath(relativePath: string): string {
|
||||
const home = os.homedir();
|
||||
return path.join(home, relativePath);
|
||||
}
|
||||
|
||||
function resolveCodexHomePath(): string {
|
||||
const configured = process.env.CODEX_HOME;
|
||||
const home = configured ? configured.replace(/^~/, os.homedir()) : resolveHomePath(".codex");
|
||||
try {
|
||||
return fs.realpathSync(home);
|
||||
} catch {
|
||||
return home;
|
||||
}
|
||||
}
|
||||
|
||||
function computeCodexKeychainAccount(codexHome: string): string {
|
||||
const hash = createHash("sha256").update(codexHome).digest("hex");
|
||||
return `cli|${hash.slice(0, 16)}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Claude Code Credentials
|
||||
// ============================================================
|
||||
|
||||
function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null {
|
||||
if (process.platform !== "darwin") return null;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
const data = JSON.parse(result.trim());
|
||||
const claudeOauth = data?.claudeAiOauth;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readClaudeCliFileCredentials(): ClaudeCliCredential | null {
|
||||
const credPath = resolveHomePath(CLAUDE_CLI_CREDENTIALS_PATH);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(credPath)) return null;
|
||||
const raw = JSON.parse(fs.readFileSync(credPath, "utf8"));
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const claudeOauth = raw.claudeAiOauth;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Claude Code CLI credentials.
|
||||
* Priority: macOS Keychain > File (~/.claude/.credentials.json)
|
||||
*/
|
||||
export function readClaudeCliCredentials(): ClaudeCliCredential | null {
|
||||
// Try keychain first (macOS only)
|
||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
||||
if (keychainCreds) return keychainCreds;
|
||||
|
||||
// Fall back to file
|
||||
return readClaudeCliFileCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Claude Code credentials exist and are valid.
|
||||
*/
|
||||
export function hasValidClaudeCliCredentials(): boolean {
|
||||
const creds = readClaudeCliCredentials();
|
||||
if (!creds) return false;
|
||||
// Check if not expired (with 5 minute buffer)
|
||||
return creds.expires > Date.now() + 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token from Claude Code credentials.
|
||||
*/
|
||||
export function getClaudeCliAccessToken(): string | null {
|
||||
const creds = readClaudeCliCredentials();
|
||||
if (!creds) return null;
|
||||
if (creds.type === "oauth") return creds.access;
|
||||
if (creds.type === "token") return creds.token;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Codex CLI Credentials
|
||||
// ============================================================
|
||||
|
||||
function readCodexKeychainCredentials(): CodexCliCredential | null {
|
||||
if (process.platform !== "darwin") return null;
|
||||
|
||||
const codexHome = resolveCodexHomePath();
|
||||
const account = computeCodexKeychainAccount(codexHome);
|
||||
|
||||
try {
|
||||
const secret = execSync(
|
||||
`security find-generic-password -s "${CODEX_CLI_KEYCHAIN_SERVICE}" -a "${account}" -w`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
).trim();
|
||||
|
||||
const parsed = JSON.parse(secret);
|
||||
const tokens = parsed.tokens;
|
||||
const accessToken = tokens?.access_token;
|
||||
const refreshToken = tokens?.refresh_token;
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
|
||||
const lastRefreshRaw = parsed.last_refresh;
|
||||
const lastRefresh =
|
||||
typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number"
|
||||
? new Date(lastRefreshRaw).getTime()
|
||||
: Date.now();
|
||||
const expires = Number.isFinite(lastRefresh)
|
||||
? lastRefresh + 60 * 60 * 1000
|
||||
: Date.now() + 60 * 60 * 1000;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires,
|
||||
accountId: typeof tokens?.account_id === "string" ? tokens.account_id : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCodexFileCredentials(): CodexCliCredential | null {
|
||||
const authPath = path.join(resolveCodexHomePath(), CODEX_CLI_AUTH_FILENAME);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(authPath)) return null;
|
||||
const raw = JSON.parse(fs.readFileSync(authPath, "utf8"));
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const tokens = raw.tokens;
|
||||
if (!tokens || typeof tokens !== "object") return null;
|
||||
|
||||
const accessToken = tokens.access_token;
|
||||
const refreshToken = tokens.refresh_token;
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
|
||||
let expires: number;
|
||||
try {
|
||||
const stat = fs.statSync(authPath);
|
||||
expires = stat.mtimeMs + 60 * 60 * 1000;
|
||||
} catch {
|
||||
expires = Date.now() + 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires,
|
||||
accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Codex CLI credentials.
|
||||
* Priority: macOS Keychain > File (~/.codex/auth.json)
|
||||
*/
|
||||
export function readCodexCliCredentials(): CodexCliCredential | null {
|
||||
// Try keychain first (macOS only)
|
||||
const keychainCreds = readCodexKeychainCredentials();
|
||||
if (keychainCreds) return keychainCreds;
|
||||
|
||||
// Fall back to file
|
||||
return readCodexFileCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Codex credentials exist and are valid.
|
||||
*/
|
||||
export function hasValidCodexCliCredentials(): boolean {
|
||||
const creds = readCodexCliCredentials();
|
||||
if (!creds) return false;
|
||||
return creds.expires > Date.now() + 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token from Codex credentials.
|
||||
*/
|
||||
export function getCodexCliAccessToken(): string | null {
|
||||
const creds = readCodexCliCredentials();
|
||||
if (!creds) return null;
|
||||
return creds.access;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Unified Interface
|
||||
// ============================================================
|
||||
|
||||
export type CliCredentialSource = "claude-code" | "codex";
|
||||
|
||||
export interface CliCredentialStatus {
|
||||
source: CliCredentialSource;
|
||||
available: boolean;
|
||||
expires?: number;
|
||||
expiresIn?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all CLI credential sources.
|
||||
*/
|
||||
export function getCliCredentialStatus(): CliCredentialStatus[] {
|
||||
const results: CliCredentialStatus[] = [];
|
||||
|
||||
// Claude Code
|
||||
const claudeCreds = readClaudeCliCredentials();
|
||||
if (claudeCreds) {
|
||||
const expiresIn = claudeCreds.expires - Date.now();
|
||||
results.push({
|
||||
source: "claude-code",
|
||||
available: expiresIn > 0,
|
||||
expires: claudeCreds.expires,
|
||||
expiresIn: formatDuration(expiresIn),
|
||||
});
|
||||
} else {
|
||||
results.push({ source: "claude-code", available: false });
|
||||
}
|
||||
|
||||
// Codex
|
||||
const codexCreds = readCodexCliCredentials();
|
||||
if (codexCreds) {
|
||||
const expiresIn = codexCreds.expires - Date.now();
|
||||
results.push({
|
||||
source: "codex",
|
||||
available: expiresIn > 0,
|
||||
expires: codexCreds.expires,
|
||||
expiresIn: formatDuration(expiresIn),
|
||||
});
|
||||
} else {
|
||||
results.push({ source: "codex", available: false });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms <= 0) return "expired";
|
||||
const hours = Math.floor(ms / (60 * 60 * 1000));
|
||||
const minutes = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000));
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
7
src/agent/providers/oauth/index.ts
Normal file
7
src/agent/providers/oauth/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* OAuth Credential Reading
|
||||
*
|
||||
* Read OAuth credentials from external CLI tools (Claude Code, Codex).
|
||||
*/
|
||||
|
||||
export * from "./cli-credentials.js";
|
||||
276
src/agent/providers/registry.ts
Normal file
276
src/agent/providers/registry.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* Provider Registry
|
||||
*
|
||||
* Central registry for all LLM providers with metadata,
|
||||
* status checking, and display formatting.
|
||||
*/
|
||||
|
||||
import { credentialManager } from "../credentials.js";
|
||||
import {
|
||||
hasValidClaudeCliCredentials,
|
||||
hasValidCodexCliCredentials,
|
||||
} from "./oauth/cli-credentials.js";
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export type AuthMethod = "api-key" | "oauth";
|
||||
|
||||
export interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
authMethod: AuthMethod;
|
||||
available: boolean;
|
||||
configured: boolean;
|
||||
current: boolean;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
loginUrl?: string | undefined;
|
||||
loginCommand?: string | undefined;
|
||||
}
|
||||
|
||||
/** Static provider metadata (without runtime status) */
|
||||
export interface ProviderMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
authMethod: AuthMethod;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
loginUrl?: string | undefined;
|
||||
loginCommand?: string | undefined;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider Registry
|
||||
// ============================================================
|
||||
|
||||
const PROVIDER_REGISTRY: Record<string, ProviderMeta> = {
|
||||
"claude-code": {
|
||||
id: "claude-code",
|
||||
name: "Claude Code (OAuth)",
|
||||
authMethod: "oauth",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
models: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"],
|
||||
loginCommand: "claude login",
|
||||
},
|
||||
"openai-codex": {
|
||||
id: "openai-codex",
|
||||
name: "Codex (OAuth)",
|
||||
authMethod: "oauth",
|
||||
defaultModel: "gpt-5.2",
|
||||
models: ["gpt-5.2", "gpt-5.2-codex", "gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5.1-codex-max"],
|
||||
loginCommand: "codex login",
|
||||
},
|
||||
"anthropic": {
|
||||
id: "anthropic",
|
||||
name: "Anthropic (API Key)",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "claude-sonnet-4-5",
|
||||
models: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"],
|
||||
loginUrl: "https://console.anthropic.com/",
|
||||
},
|
||||
"openai": {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "gpt-4o",
|
||||
models: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini"],
|
||||
loginUrl: "https://platform.openai.com/api-keys",
|
||||
},
|
||||
"kimi-coding": {
|
||||
id: "kimi-coding",
|
||||
name: "Kimi Code",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "kimi-k2-thinking",
|
||||
models: ["kimi-k2-thinking", "k2p5"],
|
||||
loginUrl: "https://kimi.moonshot.cn/",
|
||||
},
|
||||
"google": {
|
||||
id: "google",
|
||||
name: "Google AI",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "gemini-2.0-flash",
|
||||
models: ["gemini-2.0-flash", "gemini-1.5-pro"],
|
||||
loginUrl: "https://aistudio.google.com/apikey",
|
||||
},
|
||||
"groq": {
|
||||
id: "groq",
|
||||
name: "Groq",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "llama-3.3-70b-versatile",
|
||||
models: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"],
|
||||
loginUrl: "https://console.groq.com/keys",
|
||||
},
|
||||
"mistral": {
|
||||
id: "mistral",
|
||||
name: "Mistral",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "mistral-large-latest",
|
||||
models: ["mistral-large-latest", "codestral-latest"],
|
||||
loginUrl: "https://console.mistral.ai/api-keys",
|
||||
},
|
||||
"xai": {
|
||||
id: "xai",
|
||||
name: "xAI (Grok)",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "grok-beta",
|
||||
models: ["grok-beta", "grok-vision-beta"],
|
||||
loginUrl: "https://console.x.ai/",
|
||||
},
|
||||
"openrouter": {
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "anthropic/claude-3.5-sonnet",
|
||||
models: ["anthropic/claude-3.5-sonnet", "openai/gpt-4o"],
|
||||
loginUrl: "https://openrouter.ai/keys",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider alias mapping for OAuth providers.
|
||||
* Maps friendly names to actual pi-ai provider names.
|
||||
*/
|
||||
export const PROVIDER_ALIAS: Record<string, string> = {
|
||||
"claude-code": "anthropic", // Claude Code OAuth uses anthropic API
|
||||
"openai-codex": "openai", // Codex OAuth uses OpenAI API
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Status Checking
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Check if a provider is configured with API key in credentials.json5
|
||||
*/
|
||||
function isApiKeyConfigured(providerId: string): boolean {
|
||||
const config = credentialManager.getLlmProviderConfig(providerId);
|
||||
return !!config?.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth provider has valid credentials
|
||||
*/
|
||||
function isOAuthAvailable(providerId: string): boolean {
|
||||
if (providerId === "claude-code") {
|
||||
return hasValidClaudeCliCredentials();
|
||||
}
|
||||
if (providerId === "openai-codex") {
|
||||
return hasValidCodexCliCredentials();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider uses OAuth authentication
|
||||
*/
|
||||
export function isOAuthProvider(providerId: string): boolean {
|
||||
const info = PROVIDER_REGISTRY[providerId];
|
||||
return info?.authMethod === "oauth";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider is available (has valid credentials)
|
||||
*/
|
||||
export function isProviderAvailable(providerId: string): boolean {
|
||||
const info = PROVIDER_REGISTRY[providerId];
|
||||
if (!info) return false;
|
||||
|
||||
if (info.authMethod === "oauth") {
|
||||
return isOAuthAvailable(providerId);
|
||||
}
|
||||
return isApiKeyConfigured(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current provider from credentials
|
||||
*/
|
||||
export function getCurrentProvider(): string {
|
||||
return credentialManager.getLlmProvider() ?? "kimi-coding";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider Listing
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get static provider metadata
|
||||
*/
|
||||
export function getProviderMeta(providerId: string): ProviderMeta | undefined {
|
||||
return PROVIDER_REGISTRY[providerId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model for a provider
|
||||
*/
|
||||
export function getDefaultModel(providerId: string): string | undefined {
|
||||
return PROVIDER_REGISTRY[providerId]?.defaultModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all providers with their runtime status
|
||||
*/
|
||||
export function getProviderList(): ProviderInfo[] {
|
||||
const currentProvider = getCurrentProvider();
|
||||
|
||||
return Object.values(PROVIDER_REGISTRY).map((meta) => {
|
||||
const isOAuth = meta.authMethod === "oauth";
|
||||
const available = isOAuth ? isOAuthAvailable(meta.id) : isApiKeyConfigured(meta.id);
|
||||
|
||||
// Check if this is the current provider
|
||||
// For claude-code, check if current is "anthropic" and OAuth is available
|
||||
let isCurrent = currentProvider === meta.id;
|
||||
if (meta.id === "claude-code" && currentProvider === "anthropic") {
|
||||
isCurrent = hasValidClaudeCliCredentials();
|
||||
}
|
||||
|
||||
return {
|
||||
...meta,
|
||||
available,
|
||||
configured: available,
|
||||
current: isCurrent,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available providers only
|
||||
*/
|
||||
export function getAvailableProviders(): ProviderInfo[] {
|
||||
return getProviderList().filter((p) => p.available);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Display Helpers
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Format provider for display
|
||||
*/
|
||||
export function formatProviderStatus(provider: ProviderInfo): string {
|
||||
const status = provider.available ? "✓" : "✗";
|
||||
const current = provider.current ? " (current)" : "";
|
||||
const auth = provider.authMethod === "oauth" ? " [OAuth]" : "";
|
||||
return `${status} ${provider.name}${auth}${current}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get login instructions for a provider
|
||||
*/
|
||||
export function getLoginInstructions(providerId: string): string {
|
||||
const info = PROVIDER_REGISTRY[providerId];
|
||||
if (!info) return `Unknown provider: ${providerId}`;
|
||||
|
||||
if (info.authMethod === "oauth") {
|
||||
if (info.loginCommand) {
|
||||
return `Run: ${info.loginCommand}\nThen restart Super Multica to use the credentials.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (info.loginUrl) {
|
||||
return `Get your API key at: ${info.loginUrl}\nThen add it to ~/.super-multica/credentials.json5`;
|
||||
}
|
||||
|
||||
return "No login instructions available.";
|
||||
}
|
||||
166
src/agent/providers/resolver.ts
Normal file
166
src/agent/providers/resolver.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Provider Resolver
|
||||
*
|
||||
* Resolves provider configuration for making API calls,
|
||||
* including API keys, OAuth tokens, and model selection.
|
||||
*/
|
||||
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { credentialManager } from "../credentials.js";
|
||||
import {
|
||||
readClaudeCliCredentials,
|
||||
readCodexCliCredentials,
|
||||
} from "./oauth/cli-credentials.js";
|
||||
import {
|
||||
PROVIDER_ALIAS,
|
||||
getProviderMeta,
|
||||
getDefaultModel,
|
||||
isOAuthProvider,
|
||||
} from "./registry.js";
|
||||
import type { AgentOptions } from "../types.js";
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface ProviderConfig {
|
||||
provider: string;
|
||||
model?: string | undefined;
|
||||
apiKey?: string | undefined;
|
||||
baseUrl?: string | undefined;
|
||||
// OAuth specific
|
||||
accessToken?: string | undefined;
|
||||
refreshToken?: string | undefined;
|
||||
expires?: number | undefined;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider Config Resolution
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get provider config for making API calls.
|
||||
* Handles both OAuth and API Key authentication.
|
||||
*/
|
||||
export function resolveProviderConfig(providerId: string): ProviderConfig | null {
|
||||
const meta = getProviderMeta(providerId);
|
||||
if (!meta) return null;
|
||||
|
||||
if (meta.authMethod === "oauth") {
|
||||
if (providerId === "claude-code") {
|
||||
const creds = readClaudeCliCredentials();
|
||||
if (!creds) return null;
|
||||
|
||||
const accessToken = creds.type === "oauth" ? creds.access : creds.token;
|
||||
return {
|
||||
provider: "anthropic", // Use anthropic API
|
||||
apiKey: accessToken,
|
||||
accessToken,
|
||||
refreshToken: creds.type === "oauth" ? creds.refresh : undefined,
|
||||
expires: creds.expires,
|
||||
};
|
||||
}
|
||||
|
||||
if (providerId === "openai-codex") {
|
||||
const creds = readCodexCliCredentials();
|
||||
if (!creds) return null;
|
||||
|
||||
return {
|
||||
provider: "openai-codex",
|
||||
accessToken: creds.access,
|
||||
refreshToken: creds.refresh,
|
||||
expires: creds.expires,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// API Key based
|
||||
const config = credentialManager.getLlmProviderConfig(providerId);
|
||||
if (!config?.apiKey) return null;
|
||||
|
||||
return {
|
||||
provider: providerId,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Key Resolution
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get API Key based on provider.
|
||||
* Priority: explicit key > OAuth credentials > credentials.json5 config.
|
||||
*/
|
||||
export function resolveApiKey(provider: string, explicitKey?: string): string | undefined {
|
||||
if (explicitKey) return explicitKey;
|
||||
|
||||
// Try OAuth providers first (claude-code, openai-codex)
|
||||
const providerConfig = resolveProviderConfig(provider);
|
||||
if (providerConfig?.apiKey) {
|
||||
return providerConfig.apiKey;
|
||||
}
|
||||
if (providerConfig?.accessToken) {
|
||||
return providerConfig.accessToken;
|
||||
}
|
||||
|
||||
// Fall back to credentials.json5
|
||||
return credentialManager.getLlmProviderConfig(provider)?.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Base URL based on provider.
|
||||
* Priority: explicit URL > credentials.json5 config.
|
||||
*/
|
||||
export function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined {
|
||||
if (explicitUrl) return explicitUrl;
|
||||
return credentialManager.getLlmProviderConfig(provider)?.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Model ID based on provider.
|
||||
* Priority: explicit model > credentials.json5 config > default.
|
||||
*/
|
||||
export function resolveModelId(provider: string, explicitModel?: string): string | undefined {
|
||||
if (explicitModel) return explicitModel;
|
||||
return credentialManager.getLlmProviderConfig(provider)?.model ?? getDefaultModel(provider);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Model Resolution
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Resolve model for pi-ai based on provider and options.
|
||||
*/
|
||||
export function resolveModel(options: AgentOptions) {
|
||||
if (options.provider && options.model) {
|
||||
// Map provider alias (e.g., claude-code -> anthropic)
|
||||
const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider;
|
||||
|
||||
// Type assertion needed because provider/model come from dynamic user config
|
||||
return (getModel as (p: string, m: string) => ReturnType<typeof getModel>)(
|
||||
actualProvider,
|
||||
options.model,
|
||||
);
|
||||
}
|
||||
|
||||
// If only provider specified, use default model for that provider
|
||||
if (options.provider) {
|
||||
const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider;
|
||||
const defaultModel = getDefaultModel(options.provider) ?? getDefaultModel(actualProvider);
|
||||
if (defaultModel) {
|
||||
return (getModel as (p: string, m: string) => ReturnType<typeof getModel>)(
|
||||
actualProvider,
|
||||
defaultModel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return getModel("kimi-coding", "kimi-k2-thinking");
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export { isOAuthProvider };
|
||||
|
|
@ -3,6 +3,7 @@ import { v7 as uuidv7 } from "uuid";
|
|||
import type { AgentOptions, AgentRunResult } from "./types.js";
|
||||
import { createAgentOutput } from "./cli/output.js";
|
||||
import { resolveModel, resolveTools } from "./tools.js";
|
||||
import { resolveApiKey, resolveBaseUrl, resolveModelId } from "./providers/index.js";
|
||||
import { SessionManager } from "./session/session-manager.js";
|
||||
import { ProfileManager } from "./profile/index.js";
|
||||
import { SkillManager } from "./skills/index.js";
|
||||
|
|
@ -14,33 +15,6 @@ import {
|
|||
} from "./context-window/index.js";
|
||||
import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js";
|
||||
|
||||
/**
|
||||
* Get API Key based on provider.
|
||||
* Priority: explicit key > provider-specific env var > generic env var format.
|
||||
*/
|
||||
function resolveApiKey(provider: string, explicitKey?: string): string | undefined {
|
||||
if (explicitKey) return explicitKey;
|
||||
return credentialManager.getLlmProviderConfig(provider)?.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Base URL based on provider.
|
||||
* Priority: explicit URL > provider-specific env var > generic env var format.
|
||||
*/
|
||||
function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined {
|
||||
if (explicitUrl) return explicitUrl;
|
||||
return credentialManager.getLlmProviderConfig(provider)?.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Model ID based on provider.
|
||||
* Priority: explicit model > provider-specific env var > generic env var format.
|
||||
*/
|
||||
function resolveModelId(provider: string, explicitModel?: string): string | undefined {
|
||||
if (explicitModel) return explicitModel;
|
||||
return credentialManager.getLlmProviderConfig(provider)?.model;
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
private readonly agent: PiAgentCore;
|
||||
private readonly output;
|
||||
|
|
@ -155,7 +129,9 @@ export class Agent {
|
|||
const compactionMode = options.compactionMode ?? "tokens"; // 默认使用 token 模式
|
||||
|
||||
// 获取 API Key(用于 summary 模式)
|
||||
const summaryApiKey = compactionMode === "summary" ? resolveApiKey(model.provider, options.apiKey) : undefined;
|
||||
const summaryApiKey = compactionMode === "summary"
|
||||
? resolveApiKey(resolvedProvider, options.apiKey)
|
||||
: undefined;
|
||||
|
||||
// 创建 SessionManager(带 context window 配置)
|
||||
this.session = new SessionManager({
|
||||
|
|
|
|||
|
|
@ -1,32 +1,84 @@
|
|||
import type { AgentOptions } from "./types.js";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { createCodingTools } from "@mariozechner/pi-coding-agent";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { createExecTool } from "./tools/exec.js";
|
||||
import { createProcessTool } from "./tools/process.js";
|
||||
import { createGlobTool } from "./tools/glob.js";
|
||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
|
||||
import { createMemoryTools } from "./tools/memory/index.js";
|
||||
import { filterTools } from "./tools/policy.js";
|
||||
import { isMulticaError, isRetryableError } from "../shared/errors.js";
|
||||
|
||||
export function resolveModel(options: AgentOptions) {
|
||||
if (options.provider && options.model) {
|
||||
// Type assertion needed because provider/model come from dynamic user config
|
||||
return (getModel as (p: string, m: string) => ReturnType<typeof getModel>)(
|
||||
options.provider,
|
||||
options.model,
|
||||
);
|
||||
}
|
||||
return getModel("kimi-coding", "kimi-k2-thinking");
|
||||
}
|
||||
// Re-export resolveModel from providers for backwards compatibility
|
||||
export { resolveModel } from "./providers/index.js";
|
||||
|
||||
/** Options for creating tools */
|
||||
export interface CreateToolsOptions {
|
||||
cwd: string;
|
||||
/** Profile ID for memory tools (optional) */
|
||||
profileId?: string;
|
||||
profileId?: string | undefined;
|
||||
/** Base directory for profiles (optional) */
|
||||
profileBaseDir?: string;
|
||||
profileBaseDir?: string | undefined;
|
||||
}
|
||||
|
||||
type ToolErrorPayload = {
|
||||
error: true;
|
||||
message: string;
|
||||
name?: string;
|
||||
code?: string;
|
||||
retryable?: boolean;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function toToolErrorPayload(error: unknown): ToolErrorPayload {
|
||||
if (isMulticaError(error)) {
|
||||
return {
|
||||
error: true,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
code: error.code,
|
||||
retryable: error.retryable,
|
||||
details: error.details,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
error: true,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
retryable: isRetryableError(error),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: true,
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
function toolErrorResult(error: unknown): AgentToolResult<ToolErrorPayload> {
|
||||
const payload = toToolErrorPayload(error);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
||||
details: payload,
|
||||
};
|
||||
}
|
||||
|
||||
function wrapTool<TParams, TResult>(
|
||||
tool: AgentTool<TParams, TResult>,
|
||||
): AgentTool<TParams, TResult> {
|
||||
const execute = tool.execute;
|
||||
return {
|
||||
...tool,
|
||||
execute: async (...args) => {
|
||||
try {
|
||||
return await execute(...args);
|
||||
} catch (error) {
|
||||
return toolErrorResult(error) as AgentToolResult<TResult>;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -95,7 +147,7 @@ export function resolveTools(options: AgentOptions): AgentTool<any>[] {
|
|||
isSubagent: options.isSubagent,
|
||||
});
|
||||
|
||||
return filtered;
|
||||
return filtered.map((tool) => wrapTool(tool));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export interface MemoryStorageOptions {
|
|||
/** Profile ID (required for storage path) */
|
||||
profileId: string;
|
||||
/** Base directory for profiles */
|
||||
baseDir?: string;
|
||||
baseDir?: string | undefined;
|
||||
}
|
||||
|
||||
/** Result from memory_list */
|
||||
|
|
|
|||
|
|
@ -1,32 +1,6 @@
|
|||
/**
|
||||
* Tests for tool policy system.
|
||||
* Run with: npx tsx src/agent/tools/policy.test.ts
|
||||
*/
|
||||
|
||||
import { filterTools, type ToolsConfig } from "./policy.js";
|
||||
import { TOOL_GROUPS, TOOL_PROFILES, expandToolGroups } from "./groups.js";
|
||||
|
||||
// Simple test helper
|
||||
function test(name: string, fn: () => void) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (e) {
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqual<T>(actual: T, expected: T, msg?: string) {
|
||||
const actualStr = JSON.stringify(actual);
|
||||
const expectedStr = JSON.stringify(expected);
|
||||
if (actualStr !== expectedStr) {
|
||||
throw new Error(
|
||||
`${msg || "Assertion failed"}\n Expected: ${expectedStr}\n Actual: ${actualStr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterTools } from "./policy.js";
|
||||
import { TOOL_PROFILES, expandToolGroups } from "./groups.js";
|
||||
|
||||
// Mock tools for testing
|
||||
const mockTools = [
|
||||
|
|
@ -40,177 +14,171 @@ const mockTools = [
|
|||
{ name: "web_search" },
|
||||
] as any[];
|
||||
|
||||
console.log("=== Tool Groups Tests ===\n");
|
||||
|
||||
test("expandToolGroups: group:fs", () => {
|
||||
const expanded = expandToolGroups(["group:fs"]);
|
||||
assertEqual(expanded.sort(), ["edit", "glob", "read", "write"]);
|
||||
});
|
||||
|
||||
test("expandToolGroups: group:runtime", () => {
|
||||
const expanded = expandToolGroups(["group:runtime"]);
|
||||
assertEqual(expanded.sort(), ["exec", "process"]);
|
||||
});
|
||||
|
||||
test("expandToolGroups: group:web", () => {
|
||||
const expanded = expandToolGroups(["group:web"]);
|
||||
assertEqual(expanded.sort(), ["web_fetch", "web_search"]);
|
||||
});
|
||||
|
||||
test("expandToolGroups: mixed groups and tools", () => {
|
||||
const expanded = expandToolGroups(["group:runtime", "web_fetch"]);
|
||||
assertEqual(expanded.sort(), ["exec", "process", "web_fetch"]);
|
||||
});
|
||||
|
||||
console.log("\n=== Tool Profiles Tests ===\n");
|
||||
|
||||
test("TOOL_PROFILES: minimal has empty allow", () => {
|
||||
assertEqual(TOOL_PROFILES.minimal.allow, []);
|
||||
});
|
||||
|
||||
test("TOOL_PROFILES: coding has fs and runtime", () => {
|
||||
assertEqual(TOOL_PROFILES.coding.allow, ["group:fs", "group:runtime"]);
|
||||
});
|
||||
|
||||
test("TOOL_PROFILES: full has no restrictions", () => {
|
||||
assertEqual(TOOL_PROFILES.full.allow, undefined);
|
||||
assertEqual(TOOL_PROFILES.full.deny, undefined);
|
||||
});
|
||||
|
||||
console.log("\n=== Filter Tests ===\n");
|
||||
|
||||
test("filterTools: no config returns all tools", () => {
|
||||
const filtered = filterTools(mockTools, {});
|
||||
assertEqual(filtered.length, mockTools.length);
|
||||
});
|
||||
|
||||
test("filterTools: minimal profile returns no tools", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "minimal" } });
|
||||
assertEqual(filtered.length, 0);
|
||||
});
|
||||
|
||||
test("filterTools: coding profile returns fs and runtime", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "coding" } });
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
assertEqual(names, ["edit", "exec", "glob", "process", "read", "write"]);
|
||||
});
|
||||
|
||||
test("filterTools: web profile returns all", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "web" } });
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
assertEqual(names, [
|
||||
"edit",
|
||||
"exec",
|
||||
"glob",
|
||||
"process",
|
||||
"read",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"write",
|
||||
]);
|
||||
});
|
||||
|
||||
test("filterTools: full profile returns all tools", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "full" } });
|
||||
assertEqual(filtered.length, mockTools.length);
|
||||
});
|
||||
|
||||
test("filterTools: deny specific tool", () => {
|
||||
const filtered = filterTools(mockTools, { config: { deny: ["exec"] } });
|
||||
const names = filtered.map((t) => t.name);
|
||||
assertEqual(names.includes("exec"), false);
|
||||
assertEqual(names.length, mockTools.length - 1);
|
||||
});
|
||||
|
||||
test("filterTools: allow specific tools", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: { allow: ["read", "write"] },
|
||||
describe("tool groups", () => {
|
||||
it("expandToolGroups: group:fs", () => {
|
||||
const expanded = expandToolGroups(["group:fs"]);
|
||||
expect(expanded.sort()).toEqual(["edit", "glob", "read", "write"]);
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
assertEqual(names, ["read", "write"]);
|
||||
});
|
||||
|
||||
test("filterTools: deny takes precedence over allow", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: { allow: ["read", "write", "exec"], deny: ["exec"] },
|
||||
it("expandToolGroups: group:runtime", () => {
|
||||
const expanded = expandToolGroups(["group:runtime"]);
|
||||
expect(expanded.sort()).toEqual(["exec", "process"]);
|
||||
});
|
||||
|
||||
it("expandToolGroups: group:web", () => {
|
||||
const expanded = expandToolGroups(["group:web"]);
|
||||
expect(expanded.sort()).toEqual(["web_fetch", "web_search"]);
|
||||
});
|
||||
|
||||
it("expandToolGroups: mixed groups and tools", () => {
|
||||
const expanded = expandToolGroups(["group:runtime", "web_fetch"]);
|
||||
expect(expanded.sort()).toEqual(["exec", "process", "web_fetch"]);
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
assertEqual(names, ["read", "write"]);
|
||||
});
|
||||
|
||||
console.log("\n=== Provider-specific Tests ===\n");
|
||||
describe("tool profiles", () => {
|
||||
it("minimal has empty allow", () => {
|
||||
expect(TOOL_PROFILES.minimal.allow).toEqual([]);
|
||||
});
|
||||
|
||||
test("filterTools: provider-specific deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
byProvider: {
|
||||
google: { deny: ["exec", "process"] },
|
||||
it("coding has fs and runtime", () => {
|
||||
expect(TOOL_PROFILES.coding.allow).toEqual(["group:fs", "group:runtime"]);
|
||||
});
|
||||
|
||||
it("full has no restrictions", () => {
|
||||
expect(TOOL_PROFILES.full.allow).toBeUndefined();
|
||||
expect(TOOL_PROFILES.full.deny).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterTools", () => {
|
||||
it("no config returns all tools", () => {
|
||||
const filtered = filterTools(mockTools, {});
|
||||
expect(filtered.length).toBe(mockTools.length);
|
||||
});
|
||||
|
||||
it("minimal profile returns no tools", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "minimal" } });
|
||||
expect(filtered.length).toBe(0);
|
||||
});
|
||||
|
||||
it("coding profile returns fs and runtime", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "coding" } });
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]);
|
||||
});
|
||||
|
||||
it("web profile returns all", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "web" } });
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual([
|
||||
"edit",
|
||||
"exec",
|
||||
"glob",
|
||||
"process",
|
||||
"read",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"write",
|
||||
]);
|
||||
});
|
||||
|
||||
it("full profile returns all tools", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "full" } });
|
||||
expect(filtered.length).toBe(mockTools.length);
|
||||
});
|
||||
|
||||
it("deny specific tool", () => {
|
||||
const filtered = filterTools(mockTools, { config: { deny: ["exec"] } });
|
||||
const names = filtered.map((t) => t.name);
|
||||
expect(names.includes("exec")).toBe(false);
|
||||
expect(names.length).toBe(mockTools.length - 1);
|
||||
});
|
||||
|
||||
it("allow specific tools", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: { allow: ["read", "write"] },
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["read", "write"]);
|
||||
});
|
||||
|
||||
it("deny takes precedence over allow", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: { allow: ["read", "write", "exec"], deny: ["exec"] },
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["read", "write"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider-specific filtering", () => {
|
||||
it("provider-specific deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
byProvider: {
|
||||
google: { deny: ["exec", "process"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "google",
|
||||
provider: "google",
|
||||
});
|
||||
const names = filtered.map((t) => t.name);
|
||||
expect(names.includes("exec")).toBe(false);
|
||||
expect(names.includes("process")).toBe(false);
|
||||
expect(names.length).toBe(mockTools.length - 2);
|
||||
});
|
||||
const names = filtered.map((t) => t.name);
|
||||
assertEqual(names.includes("exec"), false);
|
||||
assertEqual(names.includes("process"), false);
|
||||
assertEqual(names.length, mockTools.length - 2);
|
||||
});
|
||||
|
||||
test("filterTools: provider not matching does not apply", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
byProvider: {
|
||||
google: { deny: ["exec", "process"] },
|
||||
it("provider not matching does not apply", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
byProvider: {
|
||||
google: { deny: ["exec", "process"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
provider: "openai",
|
||||
});
|
||||
expect(filtered.length).toBe(mockTools.length);
|
||||
});
|
||||
assertEqual(filtered.length, mockTools.length);
|
||||
});
|
||||
|
||||
console.log("\n=== Subagent Tests ===\n");
|
||||
|
||||
test("filterTools: subagent restrictions apply", () => {
|
||||
// Currently DEFAULT_SUBAGENT_TOOL_DENY is empty, so no tools are denied
|
||||
const filtered = filterTools(mockTools, { isSubagent: true });
|
||||
// With empty deny list, all tools are allowed
|
||||
assertEqual(filtered.length, mockTools.length);
|
||||
});
|
||||
|
||||
console.log("\n=== Combined Tests ===\n");
|
||||
|
||||
test("filterTools: profile + deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
profile: "coding",
|
||||
deny: ["exec"],
|
||||
},
|
||||
describe("subagent restrictions", () => {
|
||||
it("subagent restrictions apply", () => {
|
||||
const filtered = filterTools(mockTools, { isSubagent: true });
|
||||
expect(filtered.length).toBe(mockTools.length);
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
// coding = fs + runtime, minus exec
|
||||
assertEqual(names, ["edit", "glob", "process", "read", "write"]);
|
||||
});
|
||||
|
||||
test("filterTools: profile + provider deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
profile: "web",
|
||||
byProvider: {
|
||||
google: { deny: ["exec"] },
|
||||
describe("combined filtering", () => {
|
||||
it("profile + deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
profile: "coding",
|
||||
deny: ["exec"],
|
||||
},
|
||||
},
|
||||
provider: "google",
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["edit", "glob", "process", "read", "write"]);
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
// web profile - exec
|
||||
assertEqual(names, [
|
||||
"edit",
|
||||
"glob",
|
||||
"process",
|
||||
"read",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"write",
|
||||
]);
|
||||
});
|
||||
|
||||
console.log("\n=== All tests passed! ===\n");
|
||||
it("profile + provider deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
profile: "web",
|
||||
byProvider: {
|
||||
google: { deny: ["exec"] },
|
||||
},
|
||||
},
|
||||
provider: "google",
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
"process",
|
||||
"read",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"write",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -183,11 +183,11 @@ export function getSubagentPolicy(extraDeny?: string[]): ToolPolicy {
|
|||
|
||||
export interface FilterToolsOptions {
|
||||
/** Tool configuration */
|
||||
config?: ToolsConfig;
|
||||
config?: ToolsConfig | undefined;
|
||||
/** Current LLM provider (for provider-specific rules) */
|
||||
provider?: string;
|
||||
provider?: string | undefined;
|
||||
/** Whether this is a subagent (applies subagent restrictions) */
|
||||
isSubagent?: boolean;
|
||||
isSubagent?: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
type RequestPayload,
|
||||
type ResponseSuccessPayload,
|
||||
type ResponseErrorPayload,
|
||||
type StreamPayload,
|
||||
} from "@multica/sdk";
|
||||
import { AsyncAgent } from "../agent/async-agent.js";
|
||||
import { getHubId } from "./hub-identity.js";
|
||||
|
|
@ -23,6 +22,8 @@ import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js";
|
|||
export class Hub {
|
||||
private readonly agents = new Map<string, AsyncAgent>();
|
||||
private readonly agentSenders = new Map<string, string>();
|
||||
private readonly agentStreamIds = new Map<string, string>();
|
||||
private readonly agentStreamCounters = new Map<string, number>();
|
||||
private readonly rpc: RpcDispatcher;
|
||||
private client: GatewayClient;
|
||||
url: string;
|
||||
|
|
@ -145,13 +146,42 @@ export class Hub {
|
|||
addAgentRecord({ id: agent.sessionId, createdAt: Date.now() });
|
||||
}
|
||||
|
||||
// Internally consume messages produced by agent
|
||||
// Internally consume agent output (AgentEvent stream + error Messages)
|
||||
void this.consumeAgent(agent);
|
||||
|
||||
console.log(`Agent created: ${agent.sessionId}`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private getMessageIdFromEvent(event: unknown): string | undefined {
|
||||
if (!event || typeof event !== "object") return undefined;
|
||||
const maybeMsg = (event as { message?: unknown }).message;
|
||||
if (!maybeMsg || typeof maybeMsg !== "object") return undefined;
|
||||
const id = (maybeMsg as { id?: unknown }).id;
|
||||
return typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
}
|
||||
|
||||
private beginStream(agentId: string, event: unknown): string {
|
||||
const explicitId = this.getMessageIdFromEvent(event);
|
||||
if (explicitId) {
|
||||
this.agentStreamIds.set(agentId, explicitId);
|
||||
return explicitId;
|
||||
}
|
||||
const next = (this.agentStreamCounters.get(agentId) ?? 0) + 1;
|
||||
this.agentStreamCounters.set(agentId, next);
|
||||
const fallback = `${agentId}:${next}`;
|
||||
this.agentStreamIds.set(agentId, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private getActiveStreamId(agentId: string, event: unknown): string {
|
||||
return this.agentStreamIds.get(agentId) ?? this.getMessageIdFromEvent(event) ?? agentId;
|
||||
}
|
||||
|
||||
private endStream(agentId: string): void {
|
||||
this.agentStreamIds.delete(agentId);
|
||||
}
|
||||
|
||||
/** Internally read agent output and send via Gateway */
|
||||
private async consumeAgent(agent: AsyncAgent): Promise<void> {
|
||||
for await (const item of agent.read()) {
|
||||
|
|
@ -166,11 +196,21 @@ export class Hub {
|
|||
content: item.content,
|
||||
});
|
||||
} else {
|
||||
const maybeMessage = (item as { message?: { role?: string } }).message;
|
||||
const isAssistantMessage = maybeMessage?.role === "assistant";
|
||||
if (item.type === "message_start" && isAssistantMessage) {
|
||||
this.beginStream(agent.sessionId, item);
|
||||
}
|
||||
const streamId = this.getActiveStreamId(agent.sessionId, item);
|
||||
// Raw AgentEvent — forward via StreamAction
|
||||
this.client.send<StreamPayload>(targetDeviceId, StreamAction, {
|
||||
streamId: agent.sessionId,
|
||||
data: item,
|
||||
this.client.send(targetDeviceId, StreamAction, {
|
||||
streamId,
|
||||
agentId: agent.sessionId,
|
||||
event: item,
|
||||
});
|
||||
if (item.type === "message_end" && isAssistantMessage) {
|
||||
this.endStream(agent.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -213,6 +253,8 @@ export class Hub {
|
|||
agent.close();
|
||||
this.agents.delete(id);
|
||||
this.agentSenders.delete(id);
|
||||
this.agentStreamIds.delete(id);
|
||||
this.agentStreamCounters.delete(id);
|
||||
removeAgentRecord(id);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -221,6 +263,9 @@ export class Hub {
|
|||
for (const [id, agent] of this.agents) {
|
||||
agent.close();
|
||||
this.agents.delete(id);
|
||||
this.agentSenders.delete(id);
|
||||
this.agentStreamIds.delete(id);
|
||||
this.agentStreamCounters.delete(id);
|
||||
}
|
||||
this.client.disconnect();
|
||||
console.log("Hub shut down");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue