Merge branch 'main' into exec-approvals
This commit is contained in:
commit
3c303df8f1
61 changed files with 4538 additions and 1024 deletions
29
CLAUDE.md
29
CLAUDE.md
|
|
@ -4,13 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## Project Overview
|
||||
|
||||
Super Multica is a distributed AI agent framework with a monorepo architecture. It includes an agent engine with multi-provider LLM support, a WebSocket gateway, a console hub for multi-agent coordination, and frontend apps (Next.js web, Electron desktop).
|
||||
Super Multica is a distributed AI agent framework with a monorepo architecture. It includes an agent engine with multi-provider LLM support, an Electron desktop app with embedded Hub, a WebSocket gateway for remote access, and a Next.js web app.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
- **`src/`** — Core modules (agent engine, gateway, console, client, shared types)
|
||||
- **`src/`** — Core modules (agent engine, gateway, hub, shared types)
|
||||
- **`apps/desktop`** — Electron + Vite + React desktop app (`@multica/desktop`) — **primary development target**
|
||||
- **`apps/web`** — Next.js 16 web app (`@multica/web`, port 3001)
|
||||
- **`apps/desktop`** — Electron + Vite + React desktop app (`@multica/desktop`)
|
||||
- **`packages/ui`** — Shared UI component library (`@multica/ui`, Shadcn/Tailwind CSS v4)
|
||||
- **`packages/sdk`** — Gateway client SDK (`@multica/sdk`, Socket.io)
|
||||
- **`packages/store`** — Zustand state management (`@multica/store`)
|
||||
|
|
@ -31,15 +31,14 @@ multica profile list # List profiles
|
|||
multica skills list # List skills
|
||||
multica tools list # List tools
|
||||
multica credentials init # Initialize credentials
|
||||
multica dev # Start all dev services
|
||||
multica dev # Start desktop app (default)
|
||||
multica help # Show help
|
||||
|
||||
# Development servers
|
||||
multica dev # All services (gateway:3000, console:4000, web:3001)
|
||||
multica dev gateway # WebSocket gateway only
|
||||
multica dev console # NestJS console with agent
|
||||
multica dev # Desktop app (default, recommended)
|
||||
multica dev gateway # WebSocket gateway only (for remote clients)
|
||||
multica dev web # Next.js web app
|
||||
multica dev desktop # Electron desktop app
|
||||
multica dev all # Gateway + web app
|
||||
|
||||
# Build (turbo-orchestrated)
|
||||
pnpm build
|
||||
|
|
@ -56,18 +55,22 @@ pnpm test:coverage # With v8 coverage
|
|||
## Architecture
|
||||
|
||||
```
|
||||
Frontend (web:3001 / desktop)
|
||||
Desktop App (standalone, recommended)
|
||||
└─ Hub (embedded)
|
||||
└─ Agent Engine (LLM runner, sessions, skills, tools)
|
||||
└─ (Optional) Gateway connection for remote access
|
||||
|
||||
Web App (requires Gateway)
|
||||
→ @multica/sdk (GatewayClient, Socket.io)
|
||||
→ Gateway (NestJS, WebSocket, port 3000)
|
||||
→ Console Hub (multi-agent coordination)
|
||||
→ Agent Engine (LLM runner, sessions, skills, tools)
|
||||
→ Hub + Agent Engine
|
||||
```
|
||||
|
||||
**Agent Engine** (`src/agent/`): Orchestrates LLM interactions with multi-provider support (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, Google, Together). Features session management (JSONL-based, UUIDv7 IDs), profile system (`~/.super-multica/agent-profiles/`), modular skills with hot-reload, and token-aware context window guards (compaction modes: tokens, count, summary). Unified CLI in `src/agent/cli/index.ts` with subcommands in `src/agent/cli/commands/`.
|
||||
|
||||
**Gateway** (`src/gateway/`): NestJS WebSocket server with Socket.io for real-time message passing, RPC request/response, and streaming.
|
||||
**Hub** (`src/hub/`): Manages agents and communication channels. Embedded in desktop app, or runs standalone for web clients.
|
||||
|
||||
**Console** (`src/console/`): NestJS hub for multi-agent coordination with a web dashboard.
|
||||
**Gateway** (`src/gateway/`): NestJS WebSocket server with Socket.io for remote client access, message routing, and device verification.
|
||||
|
||||
## Tech Stack & Config
|
||||
|
||||
|
|
|
|||
55
README.md
55
README.md
|
|
@ -13,18 +13,18 @@ src/
|
|||
│ ├── skills/ # Modular skill system
|
||||
│ └── tools/ # Agent tools
|
||||
│ └── web/ # Web fetch and search tools
|
||||
├── gateway/ # WebSocket gateway for distributed communication
|
||||
├── hub/ # Multi-agent coordination hub
|
||||
├── client/ # Client library
|
||||
├── console/ # NestJS console application
|
||||
└── shared/ # Shared types and gateway SDK
|
||||
└── gateway-sdk/ # Gateway client SDK
|
||||
├── gateway/ # WebSocket gateway for remote access
|
||||
├── hub/ # Agent coordination hub
|
||||
└── shared/ # Shared types
|
||||
|
||||
apps/
|
||||
├── desktop/ # Electron desktop app (recommended)
|
||||
└── web/ # Next.js web application
|
||||
|
||||
packages/
|
||||
└── sdk/ # SDK package for external use
|
||||
├── sdk/ # Gateway client SDK
|
||||
├── store/ # Zustand state management
|
||||
└── ui/ # Shared UI components
|
||||
|
||||
skills/ # Bundled skills (commit, code-review)
|
||||
```
|
||||
|
|
@ -85,9 +85,8 @@ Example `skills.env.json5` (dynamic keys):
|
|||
Start services directly (no `source .env`):
|
||||
|
||||
```bash
|
||||
multica dev console
|
||||
multica run "hello"
|
||||
multica dev gateway
|
||||
multica dev # Start desktop app
|
||||
multica run "hello" # Run CLI mode
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
|
@ -194,10 +193,10 @@ multica chat --profile my-agent
|
|||
multica run --thinking high "solve this complex problem"
|
||||
|
||||
# Development servers
|
||||
multica dev # Start all services
|
||||
multica dev gateway # Gateway only (:3000)
|
||||
multica dev console # Console only (:4000)
|
||||
multica dev # Start desktop app (default)
|
||||
multica dev gateway # Gateway only (:3000) - for remote clients
|
||||
multica dev web # Web app only (:3001)
|
||||
multica dev all # Start gateway + web
|
||||
|
||||
# Help
|
||||
multica help
|
||||
|
|
@ -383,23 +382,33 @@ web_search({
|
|||
})
|
||||
```
|
||||
|
||||
## Distributed Architecture
|
||||
## Architecture
|
||||
|
||||
### Desktop App (Recommended)
|
||||
|
||||
The Electron desktop app runs a standalone Hub with embedded Agent Engine:
|
||||
|
||||
- **No Gateway required** for local development
|
||||
- Direct IPC communication for optimal performance
|
||||
- QR code pairing for mobile remote access
|
||||
- Optional Gateway connection for web/remote clients
|
||||
|
||||
### Gateway
|
||||
|
||||
The WebSocket gateway enables distributed multi-agent communication:
|
||||
The WebSocket gateway enables remote client access:
|
||||
|
||||
- Real-time message passing between agents
|
||||
- Real-time message routing between clients and Hub
|
||||
- Streaming support for long-running operations
|
||||
- RPC-style request/response patterns
|
||||
- Device verification and authentication
|
||||
|
||||
### Hub
|
||||
|
||||
The Hub manages multiple agents and gateway connections:
|
||||
The Hub manages agents and communication:
|
||||
|
||||
- Agent lifecycle management
|
||||
- Communication channel coordination
|
||||
- Device identification and tracking
|
||||
- Multi-subscriber event distribution
|
||||
- Device whitelist and token-based verification
|
||||
|
||||
## Scripts
|
||||
|
||||
|
|
@ -418,11 +427,11 @@ The Hub manages multiple agents and gateway connections:
|
|||
|
||||
### Development (shortcuts)
|
||||
|
||||
- `pnpm dev` - Run full stack (gateway + console + web)
|
||||
- `pnpm dev:gateway` - Run gateway only
|
||||
- `pnpm dev:console` - Run console only
|
||||
- `pnpm dev:web` - Run web app only
|
||||
- `pnpm dev` - Run desktop app (default, recommended)
|
||||
- `pnpm dev:desktop` - Run desktop app
|
||||
- `pnpm dev:gateway` - Run gateway only (for remote clients)
|
||||
- `pnpm dev:web` - Run web app only
|
||||
- `pnpm dev:all` - Run gateway + web
|
||||
|
||||
### Build & Test
|
||||
|
||||
|
|
|
|||
|
|
@ -190,28 +190,6 @@ ipcMain.handle('skills:add', async (_, source: string) => {
|
|||
|
||||
---
|
||||
|
||||
## 三、实现优先级
|
||||
|
||||
### Phase 1: 基础框架 (MVP)
|
||||
|
||||
1. **Layout 组件** - Header + Tabs 导航
|
||||
2. **Home 页面** - 二维码显示 + 连接状态
|
||||
3. **Gateway 连接** - 复用 @multica/store
|
||||
|
||||
### Phase 2: 管理功能
|
||||
|
||||
4. **Tools 页面** - 列表展示 + 开关切换
|
||||
5. **Skills 页面** - 列表展示 + 基础操作
|
||||
6. **Settings** - Gateway URL + Theme
|
||||
|
||||
### Phase 3: 完善体验
|
||||
|
||||
7. **Agent 页面** - 状态监控 + Provider 切换
|
||||
8. **二维码刷新机制**
|
||||
9. **错误处理 + Toast 提示**
|
||||
|
||||
---
|
||||
|
||||
## 四、Hub 集成技术方案
|
||||
|
||||
### 架构概述
|
||||
|
|
@ -496,9 +474,17 @@ ChatInput → useMessagesStore.sendMessage()
|
|||
|
||||
### 复用层级
|
||||
|
||||
| 层级 | 组件/模块 | 复用情况 |
|
||||
| ---------- | ---------------------------------------- | -------- |
|
||||
| UI 层 | `MessageList`, `ChatInput` | ✅ 完全复用 |
|
||||
| Store 层 | `useMessagesStore` | ✅ 完全复用 |
|
||||
| Agent 层 | `AsyncAgent.write()`, `subscribe()` | ✅ 完全复用 |
|
||||
| 传输层 | IPC vs WebSocket | ❌ 各自实现 |
|
||||
| 层级 | 组件/模块 | 复用情况 |
|
||||
| -------- | ----------------------------------- | ----------- |
|
||||
| UI 层 | `MessageList`, `ChatInput` | ✅ 完全复用 |
|
||||
| Store 层 | `useMessagesStore` | ✅ 完全复用 |
|
||||
| Agent 层 | `AsyncAgent.write()`, `subscribe()` | ✅ 完全复用 |
|
||||
| 传输层 | IPC vs WebSocket | ❌ 各自实现 |
|
||||
|
||||
---
|
||||
|
||||
## 九、TODO
|
||||
|
||||
- [ ] **优化 Memory Tool 逻辑**: 当前 memory tool 和 memory.md 没有统一,需要整合
|
||||
- [ ] **优化 Agent Profile 加载逻辑**: 改进 Profile 的加载机制
|
||||
- [ ] **Agent 自我迭代 Profile**: 添加让 Agent 在对话过程中自己修改 Profile 内文件的能力
|
||||
|
|
|
|||
31
apps/desktop/electron/electron-env.d.ts
vendored
31
apps/desktop/electron/electron-env.d.ts
vendored
|
|
@ -101,6 +101,27 @@ interface LocalChatEvent {
|
|||
}
|
||||
}
|
||||
|
||||
interface ProviderStatus {
|
||||
id: string
|
||||
name: string
|
||||
authMethod: 'api-key' | 'oauth'
|
||||
available: boolean
|
||||
configured: boolean
|
||||
current: boolean
|
||||
defaultModel: string
|
||||
models: string[]
|
||||
loginUrl?: string
|
||||
loginCommand?: string
|
||||
loginInstructions?: string
|
||||
}
|
||||
|
||||
interface CurrentProviderInfo {
|
||||
provider: string
|
||||
model: string | undefined
|
||||
providerName: string | undefined
|
||||
available: boolean
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
hub: {
|
||||
init: () => Promise<unknown>
|
||||
|
|
@ -145,6 +166,16 @@ interface ElectronAPI {
|
|||
updateStyle: (style: string) => Promise<unknown>
|
||||
updateUser: (content: string) => Promise<unknown>
|
||||
}
|
||||
provider: {
|
||||
list: () => Promise<ProviderStatus[]>
|
||||
listAvailable: () => Promise<ProviderStatus[]>
|
||||
current: () => Promise<CurrentProviderInfo>
|
||||
set: (providerId: string, modelId?: string) => Promise<{ ok: boolean; provider?: string; model?: string; error?: string }>
|
||||
getMeta: (providerId: string) => Promise<unknown>
|
||||
isAvailable: (providerId: string) => Promise<boolean>
|
||||
saveApiKey: (providerId: string, apiKey: string) => Promise<{ ok: boolean; error?: string }>
|
||||
importOAuth: (providerId: string) => Promise<{ ok: boolean; expiresAt?: number; error?: string }>
|
||||
}
|
||||
localChat: {
|
||||
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
|
||||
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const TOOL_GROUPS: Record<string, string[]> = {
|
|||
'group:runtime': ['exec', 'process'],
|
||||
'group:web': ['web_search', 'web_fetch'],
|
||||
'group:memory': ['memory_get', 'memory_set', 'memory_delete', 'memory_list'],
|
||||
'group:subagent': ['sessions_spawn'],
|
||||
}
|
||||
|
||||
// All known tool names (for display when agent not available)
|
||||
|
|
@ -21,6 +22,7 @@ const ALL_KNOWN_TOOLS = [
|
|||
...TOOL_GROUPS['group:runtime'],
|
||||
...TOOL_GROUPS['group:web'],
|
||||
...TOOL_GROUPS['group:memory'],
|
||||
...TOOL_GROUPS['group:subagent'],
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ export { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
|
|||
export { registerSkillsIpcHandlers } from './skills.js'
|
||||
export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js'
|
||||
export { registerProfileIpcHandlers } from './profile.js'
|
||||
export { registerProviderIpcHandlers } from './provider.js'
|
||||
|
||||
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
|
||||
import { registerSkillsIpcHandlers } from './skills.js'
|
||||
import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js'
|
||||
import { registerProfileIpcHandlers } from './profile.js'
|
||||
import { registerProviderIpcHandlers } from './provider.js'
|
||||
|
||||
/**
|
||||
* Register all IPC handlers.
|
||||
|
|
@ -20,6 +22,7 @@ export function registerAllIpcHandlers(): void {
|
|||
registerAgentIpcHandlers()
|
||||
registerSkillsIpcHandlers()
|
||||
registerProfileIpcHandlers()
|
||||
registerProviderIpcHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
312
apps/desktop/electron/ipc/provider.ts
Normal file
312
apps/desktop/electron/ipc/provider.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
/**
|
||||
* Provider IPC handlers for Electron main process.
|
||||
*
|
||||
* Manages LLM provider listing, status checking, and switching.
|
||||
* Mirrors the CLI `/provider` command functionality.
|
||||
*/
|
||||
import { ipcMain } from 'electron'
|
||||
import { getCurrentHub } from './hub.js'
|
||||
import {
|
||||
getProviderList,
|
||||
getAvailableProviders,
|
||||
getCurrentProvider,
|
||||
getProviderMeta,
|
||||
isProviderAvailable,
|
||||
getLoginInstructions,
|
||||
type ProviderInfo,
|
||||
} from '../../../../src/agent/providers/index.js'
|
||||
import {
|
||||
readClaudeCliCredentials,
|
||||
readCodexCliCredentials,
|
||||
} from '../../../../src/agent/providers/oauth/cli-credentials.js'
|
||||
import { credentialManager } from '../../../../src/agent/credentials.js'
|
||||
|
||||
/**
|
||||
* Provider info returned to renderer (matches ProviderInfo from registry).
|
||||
*/
|
||||
export interface ProviderStatus {
|
||||
id: string
|
||||
name: string
|
||||
authMethod: 'api-key' | 'oauth'
|
||||
available: boolean
|
||||
configured: boolean
|
||||
current: boolean
|
||||
defaultModel: string
|
||||
models: string[]
|
||||
loginUrl?: string
|
||||
loginCommand?: string
|
||||
loginInstructions?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Current provider/model info returned to renderer.
|
||||
*/
|
||||
export interface CurrentProviderInfo {
|
||||
provider: string
|
||||
model: string | undefined
|
||||
providerName: string | undefined
|
||||
available: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default agent from Hub.
|
||||
*/
|
||||
function getDefaultAgent() {
|
||||
const hub = getCurrentHub()
|
||||
if (!hub) return null
|
||||
|
||||
const agentIds = hub.listAgents()
|
||||
if (agentIds.length === 0) return null
|
||||
|
||||
return hub.getAgent(agentIds[0]) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all Provider-related IPC handlers.
|
||||
*/
|
||||
export function registerProviderIpcHandlers(): void {
|
||||
/**
|
||||
* List all providers with their status.
|
||||
* This is the main listing function, similar to CLI `/provider` command.
|
||||
*/
|
||||
ipcMain.handle('provider:list', async (): Promise<ProviderStatus[]> => {
|
||||
const providers = getProviderList()
|
||||
|
||||
return providers.map((p: ProviderInfo) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authMethod: p.authMethod,
|
||||
available: p.available,
|
||||
configured: p.configured,
|
||||
current: p.current,
|
||||
defaultModel: p.defaultModel,
|
||||
models: p.models,
|
||||
loginUrl: p.loginUrl,
|
||||
loginCommand: p.loginCommand,
|
||||
loginInstructions: getLoginInstructions(p.id),
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* List only available (configured) providers.
|
||||
*/
|
||||
ipcMain.handle('provider:listAvailable', async (): Promise<ProviderStatus[]> => {
|
||||
const providers = getAvailableProviders()
|
||||
|
||||
return providers.map((p: ProviderInfo) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authMethod: p.authMethod,
|
||||
available: p.available,
|
||||
configured: p.configured,
|
||||
current: p.current,
|
||||
defaultModel: p.defaultModel,
|
||||
models: p.models,
|
||||
loginUrl: p.loginUrl,
|
||||
loginCommand: p.loginCommand,
|
||||
loginInstructions: getLoginInstructions(p.id),
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* Get current provider and model from the active agent.
|
||||
*/
|
||||
ipcMain.handle('provider:current', async (): Promise<CurrentProviderInfo> => {
|
||||
const agent = getDefaultAgent()
|
||||
|
||||
if (agent) {
|
||||
// Get from actual agent instance
|
||||
const info = agent.getProviderInfo()
|
||||
const meta = getProviderMeta(info.provider)
|
||||
|
||||
return {
|
||||
provider: info.provider,
|
||||
model: info.model,
|
||||
providerName: meta?.name,
|
||||
available: isProviderAvailable(info.provider),
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to credentials default
|
||||
const defaultProvider = getCurrentProvider()
|
||||
const meta = getProviderMeta(defaultProvider)
|
||||
|
||||
return {
|
||||
provider: defaultProvider,
|
||||
model: meta?.defaultModel,
|
||||
providerName: meta?.name,
|
||||
available: isProviderAvailable(defaultProvider),
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Switch the agent to a different provider and/or model.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'provider:set',
|
||||
async (_event, providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> => {
|
||||
const agent = getDefaultAgent()
|
||||
|
||||
if (!agent) {
|
||||
return { ok: false, error: 'No agent available' }
|
||||
}
|
||||
|
||||
// Validate provider exists
|
||||
const meta = getProviderMeta(providerId)
|
||||
if (!meta) {
|
||||
return { ok: false, error: `Unknown provider: ${providerId}` }
|
||||
}
|
||||
|
||||
// Check if provider is available
|
||||
if (!isProviderAvailable(providerId)) {
|
||||
const instructions = getLoginInstructions(providerId)
|
||||
return {
|
||||
ok: false,
|
||||
error: `Provider "${providerId}" is not configured.\n${instructions}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate model if specified
|
||||
if (modelId && !meta.models.includes(modelId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Model "${modelId}" is not available for provider "${providerId}". Available: ${meta.models.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = agent.setProvider(providerId, modelId)
|
||||
console.log(`[IPC] Provider switched to: ${result.provider}, model: ${result.model}`)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[IPC] Failed to switch provider: ${message}`)
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get metadata for a specific provider.
|
||||
*/
|
||||
ipcMain.handle('provider:getMeta', async (_event, providerId: string) => {
|
||||
const meta = getProviderMeta(providerId)
|
||||
if (!meta) {
|
||||
return { error: `Unknown provider: ${providerId}` }
|
||||
}
|
||||
|
||||
return {
|
||||
id: meta.id,
|
||||
name: meta.name,
|
||||
authMethod: meta.authMethod,
|
||||
defaultModel: meta.defaultModel,
|
||||
models: meta.models,
|
||||
loginUrl: meta.loginUrl,
|
||||
loginCommand: meta.loginCommand,
|
||||
available: isProviderAvailable(providerId),
|
||||
loginInstructions: getLoginInstructions(providerId),
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if a specific provider is available (has valid credentials).
|
||||
*/
|
||||
ipcMain.handle('provider:isAvailable', async (_event, providerId: string): Promise<boolean> => {
|
||||
return isProviderAvailable(providerId)
|
||||
})
|
||||
|
||||
/**
|
||||
* Save API key for a provider to credentials.json5.
|
||||
* After saving, the provider should become available.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'provider:saveApiKey',
|
||||
async (_event, providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> => {
|
||||
try {
|
||||
// Validate provider exists and uses API key auth
|
||||
const meta = getProviderMeta(providerId)
|
||||
if (!meta) {
|
||||
return { ok: false, error: `Unknown provider: ${providerId}` }
|
||||
}
|
||||
if (meta.authMethod !== 'api-key') {
|
||||
return { ok: false, error: `Provider "${providerId}" uses ${meta.authMethod} authentication, not API key` }
|
||||
}
|
||||
|
||||
// Save the API key
|
||||
credentialManager.setLlmProviderApiKey(providerId, apiKey)
|
||||
console.log(`[IPC] API key saved for provider: ${providerId}`)
|
||||
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[IPC] Failed to save API key: ${message}`)
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Import OAuth credentials from CLI tools (claude-code, codex).
|
||||
* Reads from CLI credential storage and saves to credentials.json5.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'provider:importOAuth',
|
||||
async (_event, providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => {
|
||||
try {
|
||||
const meta = getProviderMeta(providerId)
|
||||
if (!meta) {
|
||||
return { ok: false, error: `Unknown provider: ${providerId}` }
|
||||
}
|
||||
if (meta.authMethod !== 'oauth') {
|
||||
return { ok: false, error: `Provider "${providerId}" does not use OAuth authentication` }
|
||||
}
|
||||
|
||||
// Read credentials from CLI tool
|
||||
if (providerId === 'claude-code') {
|
||||
const creds = readClaudeCliCredentials()
|
||||
if (!creds) {
|
||||
return { ok: false, error: 'No Claude Code credentials found. Run "claude login" first.' }
|
||||
}
|
||||
if (creds.expires <= Date.now()) {
|
||||
return { ok: false, error: 'Claude Code credentials have expired. Run "claude login" again.' }
|
||||
}
|
||||
|
||||
// Save to credentials.json5
|
||||
const token = creds.type === 'oauth' ? creds.access : creds.token
|
||||
const refreshToken = creds.type === 'oauth' ? creds.refresh : undefined
|
||||
credentialManager.setLlmProviderOAuthToken(providerId, token, refreshToken, creds.expires)
|
||||
console.log(`[IPC] OAuth credentials imported for: ${providerId}`)
|
||||
|
||||
return { ok: true, expiresAt: creds.expires }
|
||||
}
|
||||
|
||||
if (providerId === 'openai-codex') {
|
||||
const creds = readCodexCliCredentials()
|
||||
if (!creds) {
|
||||
return { ok: false, error: 'No Codex credentials found. Run "codex login" first.' }
|
||||
}
|
||||
if (creds.expires <= Date.now()) {
|
||||
return { ok: false, error: 'Codex credentials have expired. Run "codex login" again.' }
|
||||
}
|
||||
|
||||
// Save to credentials.json5
|
||||
credentialManager.setLlmProviderOAuthToken(providerId, creds.access, creds.refresh, creds.expires)
|
||||
console.log(`[IPC] OAuth credentials imported for: ${providerId}`)
|
||||
|
||||
return { ok: true, expiresAt: creds.expires }
|
||||
}
|
||||
|
||||
return { ok: false, error: `OAuth import not supported for provider: ${providerId}` }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[IPC] Failed to import OAuth credentials: ${message}`)
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -44,6 +44,27 @@ export interface ProfileData {
|
|||
userContent: string | undefined
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
id: string
|
||||
name: string
|
||||
authMethod: 'api-key' | 'oauth'
|
||||
available: boolean
|
||||
configured: boolean
|
||||
current: boolean
|
||||
defaultModel: string
|
||||
models: string[]
|
||||
loginUrl?: string
|
||||
loginCommand?: string
|
||||
loginInstructions?: string
|
||||
}
|
||||
|
||||
export interface CurrentProviderInfo {
|
||||
provider: string
|
||||
model: string | undefined
|
||||
providerName: string | undefined
|
||||
available: boolean
|
||||
}
|
||||
|
||||
// Local chat event types (for direct IPC communication without Gateway)
|
||||
export interface LocalChatEvent {
|
||||
agentId: string
|
||||
|
|
@ -134,6 +155,29 @@ const electronAPI = {
|
|||
updateUser: (content: string) => ipcRenderer.invoke('profile:updateUser', content),
|
||||
},
|
||||
|
||||
// Provider management
|
||||
provider: {
|
||||
/** List all providers with their status */
|
||||
list: (): Promise<ProviderStatus[]> => ipcRenderer.invoke('provider:list'),
|
||||
/** List only available (configured) providers */
|
||||
listAvailable: (): Promise<ProviderStatus[]> => ipcRenderer.invoke('provider:listAvailable'),
|
||||
/** Get current provider and model from the active agent */
|
||||
current: (): Promise<CurrentProviderInfo> => ipcRenderer.invoke('provider:current'),
|
||||
/** Switch the agent to a different provider and/or model */
|
||||
set: (providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> =>
|
||||
ipcRenderer.invoke('provider:set', providerId, modelId),
|
||||
/** Get metadata for a specific provider */
|
||||
getMeta: (providerId: string) => ipcRenderer.invoke('provider:getMeta', providerId),
|
||||
/** Check if a specific provider is available */
|
||||
isAvailable: (providerId: string): Promise<boolean> => ipcRenderer.invoke('provider:isAvailable', providerId),
|
||||
/** Save API key for a provider */
|
||||
saveApiKey: (providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('provider:saveApiKey', providerId, apiKey),
|
||||
/** Import OAuth credentials from CLI tools (claude-code, codex) */
|
||||
importOAuth: (providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> =>
|
||||
ipcRenderer.invoke('provider:importOAuth', providerId),
|
||||
},
|
||||
|
||||
// Local chat (direct IPC, no Gateway required)
|
||||
localChat: {
|
||||
/** Subscribe to agent events for local direct chat */
|
||||
|
|
|
|||
121
apps/desktop/src/components/api-key-dialog.tsx
Normal file
121
apps/desktop/src/components/api-key-dialog.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@multica/ui/components/ui/dialog'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { Input } from '@multica/ui/components/ui/input'
|
||||
import { Label } from '@multica/ui/components/ui/label'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Loading03Icon, Key01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
interface ApiKeyDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
providerId: string
|
||||
providerName: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function ApiKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
providerId,
|
||||
providerName,
|
||||
onSuccess,
|
||||
}: ApiKeyDialogProps) {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
setError('API key is required')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.provider.saveApiKey(providerId, apiKey.trim())
|
||||
if (result.ok) {
|
||||
setApiKey('')
|
||||
onOpenChange(false)
|
||||
onSuccess?.()
|
||||
} else {
|
||||
setError(result.error ?? 'Failed to save API key')
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setApiKey('')
|
||||
setError(null)
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={Key01Icon} className="size-5" />
|
||||
Configure {providerName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your API key to enable {providerName}. The key will be saved securely in your credentials file.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key">API Key</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !saving) {
|
||||
handleSave()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your API key is stored locally in <code className="bg-muted px-1 rounded">~/.super-multica/credentials.json5</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleClose(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !apiKey.trim()}>
|
||||
{saving && <HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin mr-2" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiKeyDialog
|
||||
146
apps/desktop/src/components/oauth-dialog.tsx
Normal file
146
apps/desktop/src/components/oauth-dialog.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@multica/ui/components/ui/dialog'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Loading03Icon, CommandLineIcon, RefreshIcon, Tick02Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
interface OAuthDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
providerId: string
|
||||
providerName: string
|
||||
loginCommand?: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function OAuthDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
providerId,
|
||||
providerName,
|
||||
loginCommand,
|
||||
onSuccess,
|
||||
}: OAuthDialogProps) {
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [expiresAt, setExpiresAt] = useState<number | null>(null)
|
||||
|
||||
const handleImport = async () => {
|
||||
setImporting(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.provider.importOAuth(providerId)
|
||||
if (result.ok) {
|
||||
setSuccess(true)
|
||||
setExpiresAt(result.expiresAt ?? null)
|
||||
// Auto-close after a short delay
|
||||
setTimeout(() => {
|
||||
onOpenChange(false)
|
||||
onSuccess?.()
|
||||
}, 1500)
|
||||
} else {
|
||||
setError(result.error ?? 'Failed to import credentials')
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(message)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
setExpiresAt(null)
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
const formatExpiry = (timestamp: number) => {
|
||||
const remaining = timestamp - Date.now()
|
||||
if (remaining <= 0) return 'expired'
|
||||
const hours = Math.floor(remaining / (60 * 60 * 1000))
|
||||
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000))
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={CommandLineIcon} className="size-5" />
|
||||
Configure {providerName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{providerName} uses OAuth authentication. Please log in via the command line first.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Login instructions */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
1. Open your terminal and run:
|
||||
</p>
|
||||
<div className="bg-muted rounded-md p-3 font-mono text-sm">
|
||||
{loginCommand ?? `${providerId} login`}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
2. Complete the login process in your browser
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
3. Click "Refresh" below to import your credentials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status messages */}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive rounded-md p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-500/10 text-green-600 dark:text-green-400 rounded-md p-3 text-sm flex items-center gap-2">
|
||||
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
|
||||
<span>
|
||||
Credentials imported successfully!
|
||||
{expiresAt && ` (expires in ${formatExpiry(expiresAt)})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleClose(false)} disabled={importing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={importing || success}>
|
||||
{importing ? (
|
||||
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={RefreshIcon} className="size-4 mr-2" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default OAuthDialog
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useMessagesStore } from '@multica/store'
|
||||
import type { ContentBlock } from '@multica/sdk'
|
||||
|
||||
interface UseLocalChatOptions {
|
||||
agentId: string
|
||||
|
|
@ -45,7 +46,15 @@ export function useLocalChat({ agentId }: UseLocalChatOptions): UseLocalChatRetu
|
|||
try {
|
||||
const result = await window.electronAPI.localChat.getHistory(agentId)
|
||||
if (result.messages && result.messages.length > 0) {
|
||||
useMessagesStore.getState().loadMessages(result.messages)
|
||||
// Normalize: IPC may return content as string, store expects ContentBlock[]
|
||||
useMessagesStore.getState().loadMessages(
|
||||
result.messages.map((m: Record<string, unknown>) => ({
|
||||
...m,
|
||||
content: typeof m.content === 'string'
|
||||
? (m.content ? [{ type: 'text' as const, text: m.content }] : [])
|
||||
: (m.content ?? []),
|
||||
})) as import('@multica/store').Message[]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// History load is best-effort
|
||||
|
|
@ -74,18 +83,17 @@ export function useLocalChat({ agentId }: UseLocalChatOptions): UseLocalChatRetu
|
|||
if (agentEvent.type === 'message_start') {
|
||||
currentStreamRef.current = streamId
|
||||
store.startStream(streamId, agentId)
|
||||
// Extract initial text if any
|
||||
const text = extractTextFromAgentEvent(agentEvent)
|
||||
if (text) store.appendStream(streamId, text)
|
||||
const content = extractContentFromAgentEvent(agentEvent)
|
||||
if (content.length) store.appendStream(streamId, content)
|
||||
} else if (agentEvent.type === 'message_update') {
|
||||
const text = extractTextFromAgentEvent(agentEvent)
|
||||
if (text && currentStreamRef.current) {
|
||||
store.appendStream(currentStreamRef.current, text)
|
||||
const content = extractContentFromAgentEvent(agentEvent)
|
||||
if (content.length && currentStreamRef.current) {
|
||||
store.appendStream(currentStreamRef.current, content)
|
||||
}
|
||||
} else if (agentEvent.type === 'message_end') {
|
||||
const text = extractTextFromAgentEvent(agentEvent)
|
||||
const content = extractContentFromAgentEvent(agentEvent)
|
||||
if (currentStreamRef.current) {
|
||||
store.endStream(currentStreamRef.current, text)
|
||||
store.endStream(currentStreamRef.current, content)
|
||||
currentStreamRef.current = null
|
||||
}
|
||||
setIsLoading(false)
|
||||
|
|
@ -131,14 +139,9 @@ export function useLocalChat({ agentId }: UseLocalChatOptions): UseLocalChatRetu
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from AgentEvent message.
|
||||
* Same logic as @multica/sdk extractTextFromEvent.
|
||||
*/
|
||||
function extractTextFromAgentEvent(event: { message?: { content?: Array<{ type: string; text?: string }> } }): string {
|
||||
if (!event.message?.content) return ''
|
||||
return event.message.content
|
||||
.filter((c): c is { type: 'text'; text: string } => c.type === 'text' && !!c.text)
|
||||
.map((c) => c.text)
|
||||
.join('')
|
||||
/** Extract content blocks from AgentEvent message */
|
||||
function extractContentFromAgentEvent(event: { message?: { content?: unknown } }): ContentBlock[] {
|
||||
if (!event.message?.content) return []
|
||||
const content = event.message.content
|
||||
return Array.isArray(content) ? content as ContentBlock[] : []
|
||||
}
|
||||
|
|
|
|||
101
apps/desktop/src/hooks/use-provider.ts
Normal file
101
apps/desktop/src/hooks/use-provider.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Hook for managing LLM providers in the Desktop App.
|
||||
*
|
||||
* Provides functionality similar to CLI `/provider` command:
|
||||
* - List all providers with status
|
||||
* - Get current provider/model
|
||||
* - Switch provider/model
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// Types are defined in electron-env.d.ts and available globally
|
||||
|
||||
interface UseProviderReturn {
|
||||
/** All providers with their status */
|
||||
providers: ProviderStatus[]
|
||||
/** Only available (configured) providers */
|
||||
availableProviders: ProviderStatus[]
|
||||
/** Current provider and model info */
|
||||
current: CurrentProviderInfo | null
|
||||
/** Loading state */
|
||||
loading: boolean
|
||||
/** Error message if any */
|
||||
error: string | null
|
||||
/** Refresh provider list and current status */
|
||||
refresh: () => Promise<void>
|
||||
/** Switch to a different provider (and optionally model) */
|
||||
setProvider: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }>
|
||||
/** Get metadata for a specific provider */
|
||||
getProviderMeta: (providerId: string) => ProviderStatus | undefined
|
||||
}
|
||||
|
||||
export function useProvider(): UseProviderReturn {
|
||||
const [providers, setProviders] = useState<ProviderStatus[]>([])
|
||||
const [current, setCurrent] = useState<CurrentProviderInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [providerList, currentInfo] = await Promise.all([
|
||||
window.electronAPI.provider.list(),
|
||||
window.electronAPI.provider.current(),
|
||||
])
|
||||
|
||||
setProviders(providerList)
|
||||
setCurrent(currentInfo)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(message)
|
||||
console.error('[useProvider] Failed to load providers:', message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load providers on mount
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
const setProvider = useCallback(async (providerId: string, modelId?: string) => {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.provider.set(providerId, modelId)
|
||||
|
||||
if (result.ok) {
|
||||
// Refresh to update current status
|
||||
await refresh()
|
||||
return { ok: true }
|
||||
} else {
|
||||
setError(result.error ?? 'Unknown error')
|
||||
return { ok: false, error: result.error }
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(message)
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
const getProviderMeta = useCallback((providerId: string) => {
|
||||
return providers.find((p) => p.id === providerId)
|
||||
}, [providers])
|
||||
|
||||
const availableProviders = providers.filter((p) => p.available)
|
||||
|
||||
return {
|
||||
providers,
|
||||
availableProviders,
|
||||
current,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
setProvider,
|
||||
getProviderMeta,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
|
|
@ -8,17 +8,52 @@ import {
|
|||
Loading03Icon,
|
||||
AlertCircleIcon,
|
||||
Edit02Icon,
|
||||
ArrowDown01Icon,
|
||||
Tick02Icon,
|
||||
Alert02Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { ConnectionQRCode } from '../components/qr-code'
|
||||
import { DeviceList } from '../components/device-list'
|
||||
import { AgentSettingsDialog } from '../components/agent-settings-dialog'
|
||||
import { ApiKeyDialog } from '../components/api-key-dialog'
|
||||
import { OAuthDialog } from '../components/oauth-dialog'
|
||||
import { useHub } from '../hooks/use-hub'
|
||||
import { useProvider } from '../hooks/use-provider'
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const { hubInfo, agents, loading, error } = useHub()
|
||||
const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider()
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [agentName, setAgentName] = useState<string | undefined>()
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
|
||||
const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
|
||||
const [selectedProvider, setSelectedProvider] = useState<{
|
||||
id: string
|
||||
name: string
|
||||
authMethod: 'api-key' | 'oauth'
|
||||
loginCommand?: string
|
||||
} | null>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setProviderDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (providerDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [providerDropdownOpen])
|
||||
|
||||
// Load agent profile info
|
||||
useEffect(() => {
|
||||
|
|
@ -151,6 +186,92 @@ export default function HomePage() {
|
|||
<p className="font-medium">{agentName || 'Unnamed Agent'}</p>
|
||||
</div>
|
||||
|
||||
{/* Provider Selector */}
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
|
||||
LLM Provider
|
||||
</p>
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-3 rounded-md bg-background border border-border hover:bg-accent/50 transition-colors disabled:opacity-50"
|
||||
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
|
||||
disabled={providerLoading || switching}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{current?.available ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-green-500" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Alert02Icon} className="size-4 text-yellow-500" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-sm">{current?.providerName ?? current?.provider ?? 'Loading...'}</p>
|
||||
<p className="text-xs text-muted-foreground">{current?.model ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
className={`size-4 text-muted-foreground transition-transform ${providerDropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Provider Dropdown - Compact Grid */}
|
||||
{providerDropdownOpen && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-10 bg-background border border-border rounded-md shadow-lg p-2">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={`flex items-center gap-1.5 px-2 py-1.5 rounded text-left text-xs transition-colors ${
|
||||
p.id === current?.provider
|
||||
? 'bg-primary/10 border border-primary/30'
|
||||
: 'hover:bg-accent/50 border border-transparent'
|
||||
} ${!p.available ? 'opacity-60 hover:opacity-80' : ''}`}
|
||||
onClick={async () => {
|
||||
if (!p.available) {
|
||||
// Show config dialog for unavailable providers
|
||||
setSelectedProvider({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authMethod: p.authMethod,
|
||||
loginCommand: p.loginCommand,
|
||||
})
|
||||
setProviderDropdownOpen(false)
|
||||
if (p.authMethod === 'oauth') {
|
||||
setOauthDialogOpen(true)
|
||||
} else {
|
||||
setApiKeyDialogOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
setSwitching(true)
|
||||
setProviderDropdownOpen(false)
|
||||
const result = await setProvider(p.id)
|
||||
setSwitching(false)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to switch provider:', result.error)
|
||||
}
|
||||
}}
|
||||
disabled={switching}
|
||||
title={`${p.name}\n${p.authMethod === 'oauth' ? 'OAuth' : 'API Key'} · ${p.defaultModel}`}
|
||||
>
|
||||
<span className={`size-1.5 rounded-full flex-shrink-0 ${
|
||||
p.available ? 'bg-green-500' : 'bg-muted-foreground/50'
|
||||
}`} />
|
||||
<span className="truncate font-medium">
|
||||
{p.id === 'claude-code' ? 'Claude Code' :
|
||||
p.id === 'openai-codex' ? 'Codex' :
|
||||
p.id === 'kimi-coding' ? 'Kimi' :
|
||||
p.id === 'anthropic' ? 'Anthropic' :
|
||||
p.id === 'openai' ? 'OpenAI' :
|
||||
p.id === 'openrouter' ? 'OpenRouter' :
|
||||
p.name.split(' ')[0]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||
|
|
@ -167,20 +288,6 @@ export default function HomePage() {
|
|||
</p>
|
||||
<p className="font-medium capitalize">{connectionState}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||
Active Agents
|
||||
</p>
|
||||
<p className="font-medium">{hubInfo?.agentCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||
Primary Agent
|
||||
</p>
|
||||
<p className="font-medium text-sm font-mono truncate" title={primaryAgent?.id}>
|
||||
{primaryAgent?.id ?? 'None'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -194,6 +301,43 @@ export default function HomePage() {
|
|||
{/* Agent Settings Dialog */}
|
||||
<AgentSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
|
||||
{/* API Key Dialog */}
|
||||
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
|
||||
<ApiKeyDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
providerId={selectedProvider.id}
|
||||
providerName={selectedProvider.name}
|
||||
onSuccess={async () => {
|
||||
// Refresh provider list and switch to the newly configured provider
|
||||
await refresh()
|
||||
const result = await setProvider(selectedProvider.id)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to switch provider:', result.error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* OAuth Dialog */}
|
||||
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
|
||||
<OAuthDialog
|
||||
open={oauthDialogOpen}
|
||||
onOpenChange={setOauthDialogOpen}
|
||||
providerId={selectedProvider.id}
|
||||
providerName={selectedProvider.name}
|
||||
loginCommand={selectedProvider.loginCommand}
|
||||
onSuccess={async () => {
|
||||
// Refresh provider list and switch to the newly configured provider
|
||||
await refresh()
|
||||
const result = await setProvider(selectedProvider.id)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to switch provider:', result.error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bottom: Actions */}
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -213,11 +357,9 @@ export default function HomePage() {
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground gap-1.5"
|
||||
disabled
|
||||
>
|
||||
<HugeiconsIcon icon={LinkSquare01Icon} className="size-4" />
|
||||
Connect to Remote Agent
|
||||
<span className="text-xs opacity-60">(Coming soon)</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"dev": "next dev --port 3001 --experimental-https",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 38 KiB |
847
docs/product-capabilities.md
Normal file
847
docs/product-capabilities.md
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
# Super Multica Product Capabilities
|
||||
|
||||
> This document is the single source of truth for all product capabilities. It describes **what exists**, not how to design or how to use it. All subsequent documents (user journeys, UI design, copywriting, design systems) should reference this document.
|
||||
|
||||
---
|
||||
|
||||
## 1. Product Definition
|
||||
|
||||
**Super Multica** is a distributed AI Agent framework. Users can create, customize, and deploy AI Agents with persistent memory, fine-grained capability control, and multi-provider LLM support. Agents run locally on the user's machine; remote access is optional.
|
||||
|
||||
**Core architecture**:
|
||||
|
||||
```
|
||||
Desktop App (standalone, recommended)
|
||||
└─ Hub (embedded, manages agents)
|
||||
└─ Agent Engine (LLM execution, sessions, skills, tools)
|
||||
└─ (Optional) Gateway connection → remote clients (web/mobile)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. User Roles
|
||||
|
||||
| Role | Definition | Platform | Authority |
|
||||
|------|-----------|----------|-----------|
|
||||
| **Owner** | Runs the Desktop app, owns Hub and Agents | Desktop (Electron) | Full: create/delete agents, approve devices, configure providers, manage profiles/skills |
|
||||
| **Collaborator** | Connects to Owner's Agent via Gateway | Web / Mobile | Limited: chat with agent, view message history. No agent management. |
|
||||
|
||||
There is no formal role/permission system. The Owner is implicit admin by virtue of running the Hub.
|
||||
|
||||
---
|
||||
|
||||
## 3. Functional Modules
|
||||
|
||||
### 3.1 Agent Engine
|
||||
|
||||
The core execution unit. An Agent receives user messages, calls an LLM, executes tools, and returns responses.
|
||||
|
||||
#### 3.1.1 Agent Lifecycle
|
||||
|
||||
| State | Description |
|
||||
|-------|-------------|
|
||||
| Created | AsyncAgent instantiated, assigned UUIDv7 session ID |
|
||||
| Idle | Awaiting `write()` call (user message) |
|
||||
| Running | Processing message: LLM call → tool execution → response |
|
||||
| Closed | Agent terminated, no further messages accepted |
|
||||
|
||||
Each `write()` call is queued. Messages are processed sequentially (one at a time).
|
||||
|
||||
#### 3.1.2 Agent Execution Loop
|
||||
|
||||
1. Receive user message via `write(content)`
|
||||
2. Resolve API credentials (with auth profile rotation)
|
||||
3. Build/update system prompt from profile
|
||||
4. Call LLM provider with message history
|
||||
5. If LLM requests tool calls → execute tools → feed results back to LLM → repeat
|
||||
6. Save all messages to session storage
|
||||
7. Check context window utilization → compact if needed
|
||||
8. Emit events to subscribers (streaming to UI)
|
||||
|
||||
#### 3.1.3 Auth Profile Rotation
|
||||
|
||||
When an API call fails, the system classifies the error and may rotate to a different API key:
|
||||
|
||||
| Error Type | Examples | Rotates? |
|
||||
|-----------|----------|----------|
|
||||
| `auth` | 401, 403, invalid key | Yes |
|
||||
| `rate_limit` | 429, rate limit exceeded | Yes |
|
||||
| `billing` | Out of credits, quota exceeded | Yes |
|
||||
| `timeout` | Connection timeout | Yes |
|
||||
| `format` | 400, malformed request | No |
|
||||
| `unknown` | Other errors | No |
|
||||
|
||||
Failed profiles enter cooldown. Rotation continues until success or all profiles exhausted.
|
||||
|
||||
Tracking file: `~/.super-multica/.auth-profiles/usage-stats.json`
|
||||
|
||||
#### 3.1.4 Subagent Spawning
|
||||
|
||||
Agents can spawn child agents via the `sessions_spawn` tool:
|
||||
|
||||
- Subagents get isolated sessions
|
||||
- Tool restrictions: `sessions_spawn` denied (no nested spawning)
|
||||
- System prompt mode: `minimal` or `none`
|
||||
- Parameters: task (required), label, model override, cleanup policy (`delete` or `keep`), timeout
|
||||
- Results announced back to parent automatically
|
||||
|
||||
#### 3.1.5 Agent Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `profileId` | string | none | Agent profile to load |
|
||||
| `provider` | string | `kimi-coding` | LLM provider |
|
||||
| `model` | string | provider default | Model within provider |
|
||||
| `reasoningMode` | `off` / `on` / `stream` | `off` | Display thinking/reasoning |
|
||||
| `compactionMode` | `count` / `tokens` / `summary` | `tokens` | Context compaction strategy |
|
||||
| `contextWindowTokens` | number | 200,000 | Override model's context window |
|
||||
| `enableSkills` | boolean | `true` | Enable skills system |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 LLM Providers
|
||||
|
||||
Ten providers supported. Two auth methods: OAuth (CLI login) and API Key.
|
||||
|
||||
| ID | Display Name | Auth | Default Model | Available Models |
|
||||
|----|-------------|------|---------------|------------------|
|
||||
| `claude-code` | Claude Code | OAuth | claude-opus-4-5 | claude-opus-4-5, claude-sonnet-4-5, claude-haiku-4-5 |
|
||||
| `openai-codex` | Codex | OAuth | gpt-5.2 | gpt-5.2, gpt-5.2-codex, gpt-5.1-codex, gpt-5.1-codex-mini, gpt-5.1-codex-max |
|
||||
| `anthropic` | Anthropic | API Key | claude-sonnet-4-5 | claude-opus-4-5, claude-sonnet-4-5, claude-haiku-4-5 |
|
||||
| `openai` | OpenAI | API Key | gpt-4o | gpt-4o, gpt-4o-mini, o1, o1-mini |
|
||||
| `kimi-coding` | Kimi Code | API Key | kimi-k2-thinking | kimi-k2-thinking, k2p5 |
|
||||
| `google` | Google AI | API Key | gemini-2.0-flash | gemini-2.0-flash, gemini-1.5-pro |
|
||||
| `groq` | Groq | API Key | llama-3.3-70b-versatile | llama-3.3-70b-versatile, mixtral-8x7b-32768 |
|
||||
| `mistral` | Mistral | API Key | mistral-large-latest | mistral-large-latest, codestral-latest |
|
||||
| `xai` | xAI (Grok) | API Key | grok-beta | grok-beta, grok-vision-beta |
|
||||
| `openrouter` | OpenRouter | API Key | anthropic/claude-3.5-sonnet | anthropic/claude-3.5-sonnet, openai/gpt-4o |
|
||||
|
||||
**Default provider fallback**: config > credentials.json5 > `kimi-coding`
|
||||
|
||||
**OAuth providers** require external CLI login (`claude login` / `codex login`).
|
||||
|
||||
**API Key providers** are configured in `~/.super-multica/credentials.json5`.
|
||||
|
||||
**Multiple API keys per provider** are supported via auth profiles (e.g., `openai`, `openai:backup`). The system rotates between them on failure.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Tools
|
||||
|
||||
Tools are capabilities the Agent can invoke during execution.
|
||||
|
||||
#### 3.3.1 Built-in Tools
|
||||
|
||||
| Tool | Category | Description |
|
||||
|------|----------|-------------|
|
||||
| `read` | File | Read file contents (with optional offset/limit) |
|
||||
| `write` | File | Create or overwrite files |
|
||||
| `edit` | File | Make precise edits to existing files |
|
||||
| `glob` | File | Find files by pattern (default limit: 100, max: 1000) |
|
||||
| `exec` | Runtime | Run shell commands (auto-backgrounds after 10s) |
|
||||
| `process` | Runtime | Manage background processes (start, stop, list, output) |
|
||||
| `web_search` | Web | Search the web (Brave or Perplexity provider) |
|
||||
| `web_fetch` | Web | Fetch and extract URL content (markdown/text, max 50k chars, 15min cache) |
|
||||
| `memory_get` | Memory | Read from agent's persistent memory |
|
||||
| `memory_set` | Memory | Write to agent's persistent memory (max 1MB per value) |
|
||||
| `memory_list` | Memory | List memory entries (default limit: 100, max: 1000) |
|
||||
| `memory_delete` | Memory | Delete memory entries |
|
||||
| `sessions_spawn` | Subagent | Spawn a child agent for a specific task |
|
||||
|
||||
#### 3.3.2 Tool Groups (shortcuts for policy)
|
||||
|
||||
| Group | Tools Included |
|
||||
|-------|---------------|
|
||||
| `group:fs` | read, write, edit, glob |
|
||||
| `group:runtime` | exec, process |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:memory` | memory_get, memory_set, memory_delete, memory_list |
|
||||
| `group:subagent` | sessions_spawn |
|
||||
| `group:core` | read, write, edit, glob, exec, process, web_search, web_fetch |
|
||||
|
||||
#### 3.3.3 Tool Policy System (3 layers)
|
||||
|
||||
| Layer | Scope | Description |
|
||||
|-------|-------|-------------|
|
||||
| 1. Global | All agents | `allow` / `deny` lists (wildcard supported: `mem*`, `*`) |
|
||||
| 2. Provider | Per LLM provider | Narrower restrictions per provider (e.g., deny `exec` for Google) |
|
||||
| 3. Subagent | Child agents only | `sessions_spawn` denied by default |
|
||||
|
||||
**Priority**: Deny always overrides Allow. Empty allow list = deny all.
|
||||
|
||||
#### 3.3.4 Exec Tool Details
|
||||
|
||||
- Default yield timeout: 10,000ms (auto-backgrounds if not complete)
|
||||
- Supports `timeoutMs` for hard kill (SIGTERM)
|
||||
- Output includes: stdout+stderr, exitCode, truncation flag, process ID if backgrounded
|
||||
|
||||
#### 3.3.5 Web Search Details
|
||||
|
||||
- Brave provider: up to 10 results, country filtering, freshness filters (`pd`/`pw`/`pm`/`py`)
|
||||
- Perplexity provider: AI-synthesized answers
|
||||
- Default count: 5 results, 1 hour cache
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Profile System
|
||||
|
||||
A Profile defines an Agent's identity, personality, knowledge, and configuration.
|
||||
|
||||
#### 3.4.1 Profile File Structure
|
||||
|
||||
```
|
||||
~/.super-multica/agent-profiles/{profileId}/
|
||||
├── soul.md # Identity: name, role, personality, behavior boundaries
|
||||
├── user.md # User information: name, preferences, context
|
||||
├── workspace.md # Workspace conventions, coding standards, project rules
|
||||
├── memory.md # Long-term knowledge base (read by agent at startup)
|
||||
├── config.json # Optional: provider, model, thinking level, tool policy
|
||||
├── memory/ # Key-value persistent memory storage
|
||||
│ ├── key1.json
|
||||
│ └── key2.json
|
||||
└── skills/ # Profile-specific skills (override global)
|
||||
└── {skill-name}/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
#### 3.4.2 Profile Config (config.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Jarvis",
|
||||
"style": "concise and direct",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinkingLevel": "medium",
|
||||
"tools": {
|
||||
"allow": ["group:fs", "web_fetch"],
|
||||
"deny": ["exec"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4.3 Profile Operations
|
||||
|
||||
| Operation | CLI | Desktop |
|
||||
|-----------|-----|---------|
|
||||
| List profiles | `multica profile list` | Via Hub info |
|
||||
| Create profile | `multica profile new <id>` | - |
|
||||
| Interactive setup | `multica profile setup <id>` | - |
|
||||
| View profile | `multica profile show <id>` | - |
|
||||
| Edit in file manager | `multica profile edit <id>` | - |
|
||||
| Delete profile | `multica profile delete <id>` | - |
|
||||
|
||||
**Profile ID rules**: alphanumeric, hyphens, underscores only.
|
||||
|
||||
#### 3.4.4 System Prompt Composition
|
||||
|
||||
The system prompt is built dynamically from profile files:
|
||||
|
||||
| Section | Source | Mode: full | Mode: minimal | Mode: none |
|
||||
|---------|--------|-----------|--------------|-----------|
|
||||
| Identity | soul.md + config | Yes | Partial | Single line |
|
||||
| User | user.md | On-demand | No | No |
|
||||
| Workspace | workspace.md | Yes | No | No |
|
||||
| Memory | memory.md | On-demand | No | No |
|
||||
| Safety | Built-in constitution | Yes | Yes | Yes |
|
||||
| Tools | Active tool list | Yes | Core only | No |
|
||||
| Skills | Skill instructions | Yes | No | No |
|
||||
| Runtime | OS, model, hostname | Yes | Essential | No |
|
||||
| Subagent | Task context | If applicable | Yes | Yes |
|
||||
|
||||
**Progressive disclosure**: soul.md, user.md, memory.md are loaded on-demand (not injected in full at startup) to save tokens.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Memory System
|
||||
|
||||
Agents can persistently store and recall information across sessions.
|
||||
|
||||
#### 3.5.1 Storage
|
||||
|
||||
- Location: `~/.super-multica/agent-profiles/{profileId}/memory/`
|
||||
- Format: One JSON file per key
|
||||
- Key rules: alphanumeric, underscore, dot, hyphen. Max 128 chars.
|
||||
- Dots in keys are escaped as `__DOT__` in filenames
|
||||
- Max value size: 1MB
|
||||
|
||||
#### 3.5.2 Entry Format
|
||||
|
||||
```json
|
||||
{
|
||||
"value": "any JSON value",
|
||||
"description": "optional human-readable description",
|
||||
"createdAt": 1717689600000,
|
||||
"updatedAt": 1717689600000
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.5.3 Memory Tools
|
||||
|
||||
| Tool | Input | Output |
|
||||
|------|-------|--------|
|
||||
| `memory_get` | `{ key }` | `{ found, value?, description?, updatedAt? }` |
|
||||
| `memory_set` | `{ key, value, description? }` | `{ success, error? }` |
|
||||
| `memory_delete` | `{ key }` | `{ success, existed, error? }` |
|
||||
| `memory_list` | `{ prefix?, limit? }` | `{ keys[], total, truncated }` |
|
||||
|
||||
**Design principle**: Agents cannot "remember" mentally. All persistence must be file-based ("TEXT > BRAIN").
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Skills System
|
||||
|
||||
Skills are modular, self-contained capabilities defined via `SKILL.md` files. They extend what an Agent can do.
|
||||
|
||||
#### 3.6.1 Skill File Format (SKILL.md)
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: Skill Name
|
||||
description: What this skill does
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
emoji: "📝"
|
||||
os: [darwin, linux] # Platform restriction (optional)
|
||||
always: false # Skip eligibility checks (optional)
|
||||
tags: [productivity, coding]
|
||||
requires:
|
||||
bins: [node, npm] # ALL must exist in PATH
|
||||
anyBins: [python3, python] # At least ONE must exist
|
||||
env: [OPENAI_API_KEY] # ALL must be set
|
||||
config: [custom.setting] # Config paths must be truthy
|
||||
---
|
||||
# Full markdown instructions follow...
|
||||
```
|
||||
|
||||
#### 3.6.2 Skill Sources & Precedence
|
||||
|
||||
| Source | Location | Precedence |
|
||||
|--------|----------|-----------|
|
||||
| Bundled | `skills/` in project | Lowest |
|
||||
| Global (user-installed) | `~/.super-multica/skills/` | Medium |
|
||||
| Profile-specific | `~/.super-multica/agent-profiles/{id}/skills/` | Highest (overrides) |
|
||||
|
||||
Profile skills with the same ID completely replace global/bundled versions.
|
||||
|
||||
#### 3.6.3 Bundled Skills
|
||||
|
||||
| Skill | ID | Description | Requirements |
|
||||
|-------|----|-------------|-------------|
|
||||
| Git Commit Helper | `commit` | Create well-formatted conventional commits | `git` binary |
|
||||
| Code Review | `code-review` | Structured code review with security focus | None |
|
||||
| Profile Setup | `profile-setup` | Interactive wizard to personalize agent profile | None |
|
||||
| Skill Creator | `skill-creator` | Create, edit, manage custom skills | None (always eligible) |
|
||||
|
||||
#### 3.6.4 Eligibility Check Sequence
|
||||
|
||||
1. Explicit disable in config → ineligible
|
||||
2. Bundled + not in allowlist → ineligible
|
||||
3. Platform mismatch (OS) → ineligible
|
||||
4. `always: true` flag → eligible (skip remaining)
|
||||
5. Missing required binary → ineligible
|
||||
6. No alternative binary found → ineligible
|
||||
7. Missing env var → ineligible
|
||||
8. Missing config path → ineligible
|
||||
9. All checks pass → eligible
|
||||
|
||||
Returns human-readable failure reasons (e.g., "Required binary not found: git").
|
||||
|
||||
#### 3.6.5 Skill Invocation
|
||||
|
||||
- **User invocation**: `/skillname args` in interactive CLI
|
||||
- **Model invocation**: Agent reads skill instructions from system prompt and follows them
|
||||
- **Hot reload**: File watcher detects SKILL.md changes, reloads automatically (250ms debounce)
|
||||
|
||||
#### 3.6.6 Skill Installation
|
||||
|
||||
```bash
|
||||
multica skills add owner/repo # Clone entire repository
|
||||
multica skills add owner/repo/skill-name # Clone single skill
|
||||
multica skills add owner/repo@branch # Specific branch/tag
|
||||
multica skills add owner/repo -p my-agent # Install to profile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Session Management
|
||||
|
||||
Sessions persist conversation history across interactions.
|
||||
|
||||
#### 3.7.1 Session Storage
|
||||
|
||||
- Location: `~/.super-multica/sessions/{sessionId}/session.jsonl`
|
||||
- Format: JSON Lines (one JSON object per line)
|
||||
- Session IDs: UUIDv7 (time-ordered)
|
||||
- Each line is either a message entry, meta entry, or compaction entry
|
||||
|
||||
#### 3.7.2 Message Format
|
||||
|
||||
Messages follow the LLM API format:
|
||||
|
||||
```json
|
||||
{"type": "message", "role": "user", "content": [{"type": "text", "text": "Hello"}]}
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hi!"}, {"type": "tool_use", "id": "...", "name": "read", "input": {"path": "/foo"}}]}
|
||||
{"type": "message", "role": "user", "content": [{"type": "tool_result", "tool_use_id": "...", "content": "file contents"}]}
|
||||
```
|
||||
|
||||
#### 3.7.3 Session Metadata
|
||||
|
||||
```json
|
||||
{"type": "meta", "provider": "anthropic", "model": "claude-sonnet-4-5", "reasoningMode": "off", "contextWindowTokens": 200000}
|
||||
```
|
||||
|
||||
#### 3.7.4 Context Window Management
|
||||
|
||||
| Parameter | Value | Description |
|
||||
|-----------|-------|-------------|
|
||||
| Hard minimum | 16,000 tokens | Block execution below this |
|
||||
| Warning threshold | 32,000 tokens | Warn if context window smaller |
|
||||
| Default context | 200,000 tokens | Fallback if model unknown |
|
||||
| Safety margin | 20% | Buffer for estimation inaccuracy |
|
||||
| Compaction trigger | 80% utilization | Start compacting |
|
||||
| Compaction target | 50% utilization | Target after compaction |
|
||||
| Min keep messages | 10 | Never remove below this |
|
||||
| Reserve tokens | 1,024 | Reserved for response generation |
|
||||
|
||||
#### 3.7.5 Compaction Modes
|
||||
|
||||
| Mode | Strategy | Speed | Quality |
|
||||
|------|----------|-------|---------|
|
||||
| `tokens` (default) | Remove oldest messages until reaching 50% target | Fast | Good (preserves recent context) |
|
||||
| `count` | Remove oldest when count > 80, keep last 60 | Fastest | Adequate |
|
||||
| `summary` | LLM generates incremental summary of removed messages | Slow (API call) | Best (preserves meaning) |
|
||||
|
||||
#### 3.7.6 Session Operations
|
||||
|
||||
| Operation | CLI Command |
|
||||
|-----------|-------------|
|
||||
| List sessions | `multica session list` |
|
||||
| View session | `multica session show <id>` (supports partial ID) |
|
||||
| Delete session | `multica session delete <id>` |
|
||||
| Resume session | `multica --session <id> "continue..."` |
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Hub
|
||||
|
||||
The Hub is the central coordinator. It manages agent lifecycle, routes messages, and handles device verification.
|
||||
|
||||
#### 3.8.1 Responsibilities
|
||||
|
||||
- Create, list, restore, close agents
|
||||
- Persist agent metadata to disk (`~/.super-multica/agents/agents.json`)
|
||||
- Route messages between local IPC and remote Gateway
|
||||
- Handle device verification and whitelisting
|
||||
- Process RPC requests from connected clients
|
||||
|
||||
#### 3.8.2 Hub RPC Methods
|
||||
|
||||
| Method | Description | Error Codes |
|
||||
|--------|-------------|-------------|
|
||||
| `verify` | Verify device with token | UNAUTHORIZED, REJECTED |
|
||||
| `getAgentMessages` | Fetch message history (default: 50, offset: 0) | INVALID_PARAMS, AGENT_NOT_FOUND |
|
||||
| `getHubInfo` | Get Hub ID and status | - |
|
||||
| `listAgents` | List all agents | - |
|
||||
| `createAgent` | Create new agent | - |
|
||||
| `deleteAgent` | Delete agent | - |
|
||||
| `updateGateway` | Update Gateway connection | - |
|
||||
|
||||
#### 3.8.3 Hub Singleton
|
||||
|
||||
One Hub per ecosystem. In Desktop mode, it's embedded in the Electron main process. It generates a persistent Hub ID stored at `~/.super-multica/hub-id`.
|
||||
|
||||
---
|
||||
|
||||
### 3.9 Gateway
|
||||
|
||||
NestJS WebSocket server that enables remote client access to the Hub.
|
||||
|
||||
#### 3.9.1 Purpose
|
||||
|
||||
Bridges remote clients (web/mobile) to the Hub. Not needed for local Desktop use.
|
||||
|
||||
#### 3.9.2 Connection Protocol
|
||||
|
||||
- Transport: Socket.io
|
||||
- Path: `/ws`
|
||||
- Port: 3000 (default)
|
||||
|
||||
#### 3.9.3 Timeouts
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Ping interval | 25 seconds |
|
||||
| Ping timeout | 20 seconds |
|
||||
| RPC default timeout | 10 seconds |
|
||||
| Verify timeout | 30 seconds |
|
||||
| Reconnect delay | 1 second |
|
||||
|
||||
#### 3.9.4 Message Routing
|
||||
|
||||
- Each message has `from` (sender device ID) and `to` (target device ID)
|
||||
- Gateway validates: sender is registered, `from` matches socket, target exists
|
||||
- Supports streaming via `StreamAction` (message_start, message_update, message_end, tool events)
|
||||
|
||||
#### 3.9.5 Error Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| NOT_REGISTERED | Sender not registered |
|
||||
| INVALID_MESSAGE | `from` field mismatch |
|
||||
| DEVICE_NOT_FOUND | Target device not online |
|
||||
|
||||
---
|
||||
|
||||
### 3.10 Device Pairing & Verification
|
||||
|
||||
How remote devices (web/mobile) connect to the Owner's Hub.
|
||||
|
||||
#### 3.10.1 QR Code Generation (Desktop)
|
||||
|
||||
The Desktop app generates a QR code containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "multica-connect",
|
||||
"gateway": "http://localhost:3000",
|
||||
"hubId": "uuid",
|
||||
"agentId": "uuid",
|
||||
"token": "random-uuid",
|
||||
"expires": 1694000000000
|
||||
}
|
||||
```
|
||||
|
||||
- Token: one-time use, random UUID
|
||||
- Expiry: 30 seconds from generation
|
||||
- Auto-refresh: new token generated when expired
|
||||
- Also available as URL: `multica://connect?gateway=...&hub=...&agent=...&token=...&exp=...`
|
||||
|
||||
#### 3.10.2 Connection Code Formats (accepted by client)
|
||||
|
||||
| Format | Example |
|
||||
|--------|---------|
|
||||
| JSON | `{"type":"multica-connect","gateway":"..."}` |
|
||||
| Base64 JSON | Base64-encoded JSON string |
|
||||
| URL | `multica://connect?gateway=...&hub=...&agent=...&token=...&exp=...` |
|
||||
|
||||
#### 3.10.3 Verification Flow
|
||||
|
||||
```
|
||||
1. Mobile scans QR / pastes code
|
||||
2. Client parses code, validates expiry
|
||||
3. Client connects to Gateway via Socket.io
|
||||
4. Gateway sends "registered" event
|
||||
5. Client auto-sends "verify" RPC with token + device metadata
|
||||
6. Hub validates token (one-time, checks expiry)
|
||||
7. Hub triggers confirmation dialog on Desktop
|
||||
- Shows: device name (parsed from User-Agent), device ID
|
||||
- Options: "Allow" or "Reject"
|
||||
- Timeout: 60 seconds (auto-reject)
|
||||
8. If allowed: device added to whitelist, persisted to disk
|
||||
9. If rejected: connection closed
|
||||
```
|
||||
|
||||
#### 3.10.4 Device Whitelist
|
||||
|
||||
- Location: `~/.super-multica/client-devices/whitelist.json`
|
||||
- Format:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"devices": [{
|
||||
"deviceId": "uuid",
|
||||
"agentId": "uuid",
|
||||
"addedAt": 1694000000000,
|
||||
"meta": {
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"platform": "Linux",
|
||||
"language": "en-US"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.10.5 Reconnection (whitelisted device)
|
||||
|
||||
Whitelisted devices reconnect without needing a new token or user confirmation. Hub checks `isAllowed(deviceId)` and returns immediately.
|
||||
|
||||
#### 3.10.6 Device Management (Desktop)
|
||||
|
||||
- View verified devices list with metadata
|
||||
- Revoke individual devices (remove from whitelist)
|
||||
- No fine-grained permissions (all-or-nothing access)
|
||||
|
||||
#### 3.10.7 Security Model
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| Token lifetime | 30 seconds |
|
||||
| Token usage | One-time (deleted after consumption) |
|
||||
| Token storage | In-memory only (lost on Hub restart) |
|
||||
| Device ID | Browser: UUID in localStorage. Persistent until cleared. |
|
||||
| Whitelist | Persisted to disk. Survives restarts. |
|
||||
| Authorization | All verified devices have equal access |
|
||||
| Message auth | Hub checks whitelist on every non-verify message |
|
||||
|
||||
---
|
||||
|
||||
### 3.11 Credentials System
|
||||
|
||||
#### 3.11.1 Files
|
||||
|
||||
| File | Purpose | Permissions |
|
||||
|------|---------|-------------|
|
||||
| `~/.super-multica/credentials.json5` | LLM providers + tool API keys | 0o600 |
|
||||
| `~/.super-multica/skills.env.json5` | Skill/plugin environment variables | 0o600 |
|
||||
|
||||
Format: JSON5 (supports comments, trailing commas, unquoted keys).
|
||||
|
||||
#### 3.11.2 credentials.json5 Structure
|
||||
|
||||
```json5
|
||||
{
|
||||
version: 1,
|
||||
llm: {
|
||||
provider: "openai", // Default provider
|
||||
providers: {
|
||||
openai: { apiKey: "sk-...", model: "gpt-4o" },
|
||||
anthropic: { apiKey: "sk-ant-...", model: "claude-sonnet-4-5" },
|
||||
"openai:backup": { apiKey: "sk-..." }, // Auth profile for rotation
|
||||
},
|
||||
order: {
|
||||
openai: ["openai", "openai:backup"], // Rotation order
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
brave: { apiKey: "brv-..." },
|
||||
perplexity: { apiKey: "pplx-...", model: "perplexity/sonar-pro" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.11.3 skills.env.json5 Structure
|
||||
|
||||
```json5
|
||||
{
|
||||
env: {
|
||||
LINEAR_API_KEY: "lin-...",
|
||||
GITHUB_TOKEN: "ghp_...",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.11.4 Environment Variable Overrides
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `SMC_CREDENTIALS_PATH` | Override credentials.json5 path |
|
||||
| `SMC_SKILLS_ENV_PATH` | Override skills.env.json5 path |
|
||||
| `SMC_CREDENTIALS_DISABLE=1` | Disable credentials loading |
|
||||
|
||||
---
|
||||
|
||||
## 4. Platform Details
|
||||
|
||||
### 4.1 Desktop App (Primary)
|
||||
|
||||
**Technology**: Electron + Vite + React 19
|
||||
|
||||
**Window**: 1200x800, context isolation enabled, node integration disabled
|
||||
|
||||
#### 4.1.1 Pages
|
||||
|
||||
| Route | Page | Purpose |
|
||||
|-------|------|---------|
|
||||
| `/` | Home | Hub status, QR code, provider selector, agent settings, device list |
|
||||
| `/chat` | Chat | Message history, chat input, mode switcher (local/remote) |
|
||||
| `/tools` | Tools | Tool listing and inspection |
|
||||
| `/skills` | Skills | Skill listing and management |
|
||||
|
||||
**Navigation**: Tab bar at top (Home, Chat, Tools, Skills)
|
||||
|
||||
#### 4.1.2 Home Page Components
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| QR Code | Left side. Shows connection code with 30s countdown. Refresh/copy link buttons. |
|
||||
| Hub Status | Right side. Hub ID, connection state indicator (green/yellow/red). |
|
||||
| Agent Settings | Agent name (editable). |
|
||||
| Provider Selector | Dropdown showing all providers with availability status. API Key dialog or OAuth dialog based on provider type. |
|
||||
| Device List | Verified devices with name, platform, revoke button. |
|
||||
| Open Chat | Button. Disabled if Hub not connected. |
|
||||
| Connect to Remote Agent | Button. Navigate to remote agent connection. |
|
||||
|
||||
#### 4.1.3 Chat Page Modes
|
||||
|
||||
| Mode | Transport | When Used |
|
||||
|------|-----------|-----------|
|
||||
| Local Agent | IPC (Electron) | Desktop user talks directly to embedded agent |
|
||||
| Remote Agent | WebSocket via Gateway | Desktop user connects to another Hub's agent |
|
||||
|
||||
Mode switcher available at top of chat page.
|
||||
|
||||
#### 4.1.4 Desktop IPC Channels
|
||||
|
||||
| Channel | Direction | Purpose |
|
||||
|---------|-----------|---------|
|
||||
| `localChat:send` | Renderer → Main | Send message to agent |
|
||||
| `localChat:subscribe` | Renderer → Main | Subscribe to agent events |
|
||||
| `hub:device-confirm-request` | Main → Renderer | Show device confirmation dialog |
|
||||
| `hub:device-confirm-response` | Renderer → Main | User's allow/reject decision |
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Web App
|
||||
|
||||
**Technology**: Next.js 16 + App Router
|
||||
|
||||
**Port**: 3001
|
||||
|
||||
**Features**:
|
||||
- Always requires Gateway connection (no local agent)
|
||||
- Uses shared `@multica/ui` Chat component
|
||||
- PWA-capable (service worker, offline page)
|
||||
- Responsive layout (mobile-first)
|
||||
- Light/dark theme toggle
|
||||
|
||||
**Page**: Single page rendering `<Chat />` component with `ConnectPrompt` for initial connection.
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Mobile App
|
||||
|
||||
**Technology**: Expo + React Native
|
||||
|
||||
**Status**: Demo/prototype (hardcoded mock messages)
|
||||
|
||||
**Features**:
|
||||
- QR code scanner for device pairing
|
||||
- Keyboard-avoiding input bar
|
||||
- Auto-expanding text input (max 120px)
|
||||
- Auto-scroll to bottom on new messages
|
||||
|
||||
---
|
||||
|
||||
### 4.4 CLI
|
||||
|
||||
**Entry point**: `multica` (alias: `mu`)
|
||||
|
||||
#### 4.4.1 Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `multica` | Interactive chat mode (default) |
|
||||
| `multica run "<prompt>"` | Non-interactive single prompt |
|
||||
| `multica chat` | Explicit interactive mode |
|
||||
| `multica session list/show/delete` | Session management |
|
||||
| `multica profile list/new/setup/show/edit/delete` | Profile management |
|
||||
| `multica skills list/status/install/add/remove` | Skill management |
|
||||
| `multica tools list/groups/profiles` | Tool inspection |
|
||||
| `multica credentials init/show/edit` | Credentials management |
|
||||
| `multica dev [service]` | Development servers |
|
||||
|
||||
#### 4.4.2 Interactive Mode Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | Show help |
|
||||
| `/exit` `/quit` `/q` | Exit |
|
||||
| `/clear` | Clear session |
|
||||
| `/session` | Show current session ID |
|
||||
| `/new` | Start new session |
|
||||
| `/multiline` | Toggle multi-line input mode |
|
||||
| `/provider` | Show provider status |
|
||||
| `/model [name]` | Switch model |
|
||||
| `/{skillName} [args]` | Execute skill |
|
||||
|
||||
**Features**: Autocomplete (Shift+Tab), status bar (session/provider/model), multi-line mode (end with `.`).
|
||||
|
||||
#### 4.4.3 Development Servers
|
||||
|
||||
| Service | Command | Port |
|
||||
|---------|---------|------|
|
||||
| Desktop (default) | `multica dev` | Electron window |
|
||||
| Gateway | `multica dev gateway` | 3000 |
|
||||
| Web | `multica dev web` | 3001 |
|
||||
| All | `multica dev all` | 3000 + 3001 |
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Component Library
|
||||
|
||||
Shared package: `@multica/ui`. Used by Desktop, Web, and Mobile.
|
||||
|
||||
### 5.1 Chat Components
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Chat` | (none, uses stores) | Full chat view: connect prompt + message list + input |
|
||||
| `ChatInput` | `onSubmit`, `disabled`, `placeholder` | Tiptap editor. Enter=send, Shift+Enter=newline, IME-safe |
|
||||
| `ChatInputRef` | (imperative) | `getText()`, `setText()`, `focus()`, `clear()` |
|
||||
| `MessageList` | `messages`, `streamingIds` | Renders messages with markdown, tool calls, streaming |
|
||||
| `ConnectPrompt` | (none, uses stores) | QR scan + paste code UI for remote connection |
|
||||
| `ChatSkeleton` | (none) | Loading skeleton |
|
||||
| `ToolCallItem` | `message` | Tool execution display: status dot, label, subtitle, expandable results |
|
||||
|
||||
### 5.2 Markdown Components
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Markdown` | `children`, `mode` (`minimal`/`full`) | Rendered markdown with syntax highlighting |
|
||||
| `StreamingMarkdown` | `content`, `isStreaming`, `mode` | Incremental markdown with animated cursor |
|
||||
| `CodeBlock` | (internal) | Syntax-highlighted code block with copy button |
|
||||
|
||||
### 5.3 Base UI Components (Shadcn/UI)
|
||||
|
||||
button, input, textarea, card, dialog, alert-dialog, dropdown-menu, select, combobox, badge, label, field, input-group, switch, skeleton, separator, sheet, sidebar, tooltip, sonner (toasts)
|
||||
|
||||
### 5.4 Utility Components
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `QRScannerView` | Camera-based QR scanner |
|
||||
| `QRScannerSheet` | Sheet variant of QR scanner |
|
||||
| `Spinner` | Animated loading spinner |
|
||||
| `ThemeProvider` | Light/dark theme context |
|
||||
| `ThemeToggle` | Theme switch button |
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Persistence Locations
|
||||
|
||||
| Data | Location | Format | Lifetime |
|
||||
|------|----------|--------|----------|
|
||||
| Credentials | `~/.super-multica/credentials.json5` | JSON5 | User-managed |
|
||||
| Skills env | `~/.super-multica/skills.env.json5` | JSON5 | User-managed |
|
||||
| Agent profiles | `~/.super-multica/agent-profiles/{id}/` | MD + JSON | User-managed |
|
||||
| Agent memory | `~/.super-multica/agent-profiles/{id}/memory/` | JSON per key | Agent-managed |
|
||||
| Sessions | `~/.super-multica/sessions/{id}/session.jsonl` | JSONL | Until deleted |
|
||||
| Agent records | `~/.super-multica/agents/agents.json` | JSON | Persistent |
|
||||
| Hub ID | `~/.super-multica/hub-id` | Plain text UUID | Generated once |
|
||||
| Device whitelist | `~/.super-multica/client-devices/whitelist.json` | JSON | Until revoked |
|
||||
| Auth profile stats | `~/.super-multica/.auth-profiles/usage-stats.json` | JSON | Runtime tracking |
|
||||
| Verification tokens | In-memory | Map | Lost on restart |
|
||||
| Browser device ID | localStorage: `multica-device` | UUID string | Until cleared |
|
||||
| Saved connection | localStorage: `multica-connection` | JSON | Until disconnected |
|
||||
|
||||
---
|
||||
|
||||
## 7. Current Limitations
|
||||
|
||||
| Area | Limitation | Notes |
|
||||
|------|-----------|-------|
|
||||
| Agent count | Desktop creates 1 primary agent on startup | Hub API supports multi-agent (`createAgent`/`listAgents`), but UI only shows one |
|
||||
| Device permissions | All-or-nothing access | No per-device capability restrictions |
|
||||
| Role system | No formal RBAC | Owner is implicit admin |
|
||||
| Mobile app | Demo/prototype | Hardcoded mock data, no real agent connection |
|
||||
| Offline web | PWA shell only | Cannot function without Gateway |
|
||||
| Skill marketplace | No registry | Install via GitHub URL only |
|
||||
| Real-time collaboration | Single agent, sequential messages | No concurrent message processing |
|
||||
| File upload | Not supported | Agent can only read files on Owner's filesystem |
|
||||
|
||||
---
|
||||
|
||||
*Document generated: 2026-02-05*
|
||||
*Source: codebase analysis at commit fc6c3e3 on branch feat/mobile-pwa-optimization*
|
||||
|
|
@ -12,10 +12,10 @@
|
|||
"multica": "tsx src/agent/cli/index.ts",
|
||||
"mu": "tsx src/agent/cli/index.ts",
|
||||
"dev": "tsx src/agent/cli/index.ts dev",
|
||||
"dev:gateway": "tsx src/agent/cli/index.ts dev gateway",
|
||||
"dev:console": "tsx src/agent/cli/index.ts dev console",
|
||||
"dev:web": "tsx src/agent/cli/index.ts dev web",
|
||||
"dev:desktop": "tsx src/agent/cli/index.ts dev desktop",
|
||||
"dev:gateway": "tsx src/agent/cli/index.ts dev gateway",
|
||||
"dev:web": "tsx src/agent/cli/index.ts dev web",
|
||||
"dev:all": "tsx src/agent/cli/index.ts dev all",
|
||||
"build": "turbo build",
|
||||
"build:sdk": "pnpm --filter @multica/sdk build",
|
||||
"build:cli": "node scripts/build-cli.js",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-agent-core": "^0.50.3",
|
||||
"@mariozechner/pi-ai": "^0.50.3",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,10 +33,12 @@ export {
|
|||
export {
|
||||
StreamAction,
|
||||
type StreamPayload,
|
||||
type StreamEvent,
|
||||
type StreamMessageEvent,
|
||||
type StreamToolEvent,
|
||||
extractTextFromEvent,
|
||||
type AgentEvent,
|
||||
type ContentBlock,
|
||||
type TextContent,
|
||||
type ThinkingContent,
|
||||
type ToolCall,
|
||||
type ImageContent,
|
||||
extractThinkingFromEvent,
|
||||
} from "./stream";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
/** RPC Actions - 请求/响应模式 */
|
||||
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
|
||||
export const RequestAction = "request" as const;
|
||||
export const ResponseAction = "response" as const;
|
||||
|
||||
|
|
@ -65,34 +67,11 @@ export interface GetAgentMessagesParams {
|
|||
limit?: number;
|
||||
}
|
||||
|
||||
/** Content block types from the agent engine */
|
||||
export interface TextContentBlock {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ThinkingContentBlock {
|
||||
type: "thinking";
|
||||
thinking: string;
|
||||
}
|
||||
|
||||
export interface ToolCallBlock {
|
||||
type: "tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
export interface ImageContentBlock {
|
||||
type: "image";
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Agent message returned by getAgentMessages (mirrors pi-ai Message) */
|
||||
export type AgentMessageItem =
|
||||
| { role: "user"; content: string | (TextContentBlock | ImageContentBlock)[]; timestamp: number }
|
||||
| { role: "assistant"; content: (TextContentBlock | ThinkingContentBlock | ToolCallBlock)[]; timestamp: number }
|
||||
| { role: "tool_result"; toolCallId: string; content: (TextContentBlock | ImageContentBlock)[]; isError: boolean; timestamp: number }
|
||||
/**
|
||||
* Agent message returned by getAgentMessages.
|
||||
* This is pi-ai's Message type — the backend returns it as-is from SessionManager.loadMessages().
|
||||
*/
|
||||
export type AgentMessageItem = Message;
|
||||
|
||||
/** getAgentMessages - response payload */
|
||||
export interface GetAgentMessagesResult {
|
||||
|
|
|
|||
|
|
@ -1,59 +1,51 @@
|
|||
/** Stream Action - 流式消息传输 */
|
||||
/** Stream Action */
|
||||
|
||||
export const StreamAction = "stream" as const;
|
||||
|
||||
// --- Content block types (re-exported from pi-ai, the single source of truth) ---
|
||||
|
||||
import type {
|
||||
TextContent,
|
||||
ThinkingContent,
|
||||
ToolCall,
|
||||
ImageContent,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
||||
|
||||
export type { TextContent, ThinkingContent, ToolCall, ImageContent };
|
||||
export type { AgentEvent };
|
||||
|
||||
/**
|
||||
* AgentEvent types forwarded by the Hub to frontend clients.
|
||||
* These mirror the subset of AgentEvent from @mariozechner/pi-agent-core
|
||||
* that the Hub forwards (filtered at the Hub layer).
|
||||
* Convenience union of all content block types across message roles.
|
||||
*
|
||||
* NOTE: This is a deliberate simplification. The backend uses narrower unions
|
||||
* per role (e.g. AssistantMessage.content excludes ImageContent, UserMessage
|
||||
* excludes ThinkingContent/ToolCall). We accept the wider union on the frontend
|
||||
* for simpler handling — the backend already guarantees correctness.
|
||||
*/
|
||||
export interface StreamMessageEvent {
|
||||
type: "message_start" | "message_update" | "message_end";
|
||||
message: {
|
||||
id?: string;
|
||||
role: string;
|
||||
content?: Array<{ type: string; text?: string; thinking?: string }>;
|
||||
};
|
||||
assistantMessageEvent?: unknown;
|
||||
}
|
||||
export type ContentBlock = TextContent | ThinkingContent | ToolCall | ImageContent;
|
||||
|
||||
export interface StreamToolEvent {
|
||||
type: "tool_execution_start" | "tool_execution_end";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
// --- Stream event types ---
|
||||
|
||||
export type StreamEvent = StreamMessageEvent | StreamToolEvent;
|
||||
|
||||
/** 流消息 payload — wraps a raw AgentEvent with stream/agent identifiers */
|
||||
/**
|
||||
* Hub forwards AgentEvent from pi-agent-core as-is.
|
||||
* StreamPayload wraps it with routing metadata.
|
||||
*/
|
||||
export interface StreamPayload {
|
||||
/** 流 ID,关联同一个流的所有消息 */
|
||||
streamId: string;
|
||||
/** 所属 agent ID */
|
||||
agentId: string;
|
||||
/** Raw agent event from the engine */
|
||||
event: StreamEvent;
|
||||
event: AgentEvent;
|
||||
}
|
||||
|
||||
/** Extract plain text from an AgentMessage content array */
|
||||
export function extractTextFromEvent(event: StreamMessageEvent): string {
|
||||
const content = event.message?.content;
|
||||
/** Extract thinking/reasoning content from an AgentEvent that carries a message */
|
||||
export function extractThinkingFromEvent(event: AgentEvent): string {
|
||||
if (!("message" in event)) return "";
|
||||
const msg = event.message;
|
||||
if (!msg || !("content" in msg)) return "";
|
||||
const content = msg.content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Extract thinking/reasoning content from an AgentMessage content array */
|
||||
export function extractThinkingFromEvent(event: StreamMessageEvent): string {
|
||||
const content = event.message?.content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "thinking")
|
||||
.filter((c): c is ThinkingContent => c.type === "thinking")
|
||||
.map((c) => c.thinking ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ export class GatewayClient {
|
|||
}
|
||||
|
||||
/** Hub 验证成功回调 */
|
||||
onVerified(callback: (result: { hubId: string; agentId: string }) => void): this {
|
||||
onVerified(callback: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void): this {
|
||||
this.callbacks.onVerified = callback;
|
||||
return this;
|
||||
}
|
||||
|
|
@ -312,12 +312,13 @@ export class GatewayClient {
|
|||
if (this.options.hubId) {
|
||||
// Set internal state to allow send/request during verify
|
||||
this._state = "registered";
|
||||
this.callbacks.onStateChange?.("verifying");
|
||||
const meta = typeof navigator !== "undefined" ? {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
} : undefined;
|
||||
this.request<{ hubId: string; agentId: string }>(
|
||||
this.request<{ hubId: string; agentId: string; isNewDevice?: boolean }>(
|
||||
this.options.hubId,
|
||||
"verify",
|
||||
{ token: this.options.token, meta },
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export type ConnectionState =
|
|||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "verifying"
|
||||
| "registered";
|
||||
|
||||
/** Event callback types */
|
||||
|
|
@ -109,7 +110,7 @@ export interface GatewayClientCallbacks {
|
|||
onConnect?: (socketId: string) => void;
|
||||
onDisconnect?: (reason: string) => void;
|
||||
onRegistered?: (deviceId: string) => void;
|
||||
onVerified?: (result: { hubId: string; agentId: string }) => void;
|
||||
onVerified?: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void;
|
||||
onMessage?: (message: RoutedMessage) => void;
|
||||
onSendError?: (error: SendErrorResponse) => void;
|
||||
onPong?: (data: string) => void;
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ import { v7 as uuidv7 } from "uuid"
|
|||
import {
|
||||
GatewayClient,
|
||||
StreamAction,
|
||||
extractTextFromEvent,
|
||||
type ConnectionState,
|
||||
type SendErrorResponse,
|
||||
type StreamPayload,
|
||||
type StreamMessageEvent,
|
||||
type AgentEvent,
|
||||
type GetAgentMessagesResult,
|
||||
type ContentBlock,
|
||||
} from "@multica/sdk"
|
||||
import { useMessagesStore } from "./messages"
|
||||
import { useMessagesStore, type Message } from "./messages"
|
||||
import { clearConnection, type ConnectionInfo } from "./connection"
|
||||
|
||||
interface ConnectionStoreState {
|
||||
|
|
@ -34,7 +34,9 @@ interface ConnectionStoreState {
|
|||
hubId: string | null
|
||||
agentId: string | null
|
||||
connectionState: ConnectionState
|
||||
lastError: SendErrorResponse | null
|
||||
lastError: { code: string; message: string } | null
|
||||
/** Whether the current connection required Owner approval (new device) */
|
||||
isNewDevice: boolean | null
|
||||
}
|
||||
|
||||
interface ConnectionStoreActions {
|
||||
|
|
@ -104,34 +106,63 @@ function createClient(
|
|||
switch (event.type) {
|
||||
case "message_start": {
|
||||
store.startStream(payload.streamId, payload.agentId)
|
||||
const text = extractTextFromEvent(event as StreamMessageEvent)
|
||||
if (text) store.appendStream(payload.streamId, text)
|
||||
const content = extractContent(event)
|
||||
if (content.length) store.appendStream(payload.streamId, content)
|
||||
break
|
||||
}
|
||||
case "message_update": {
|
||||
const text = extractTextFromEvent(event as StreamMessageEvent)
|
||||
store.appendStream(payload.streamId, text)
|
||||
const content = extractContent(event)
|
||||
store.appendStream(payload.streamId, content)
|
||||
break
|
||||
}
|
||||
case "message_end": {
|
||||
const text = extractTextFromEvent(event as StreamMessageEvent)
|
||||
store.endStream(payload.streamId, text)
|
||||
const content = extractContent(event)
|
||||
const stopReason = "message" in event
|
||||
? (event.message as { stopReason?: string })?.stopReason
|
||||
: undefined
|
||||
store.endStream(payload.streamId, content, stopReason)
|
||||
break
|
||||
}
|
||||
case "tool_execution_start":
|
||||
case "tool_execution_end":
|
||||
case "tool_execution_start": {
|
||||
store.startToolExecution(
|
||||
payload.agentId,
|
||||
event.toolCallId,
|
||||
event.toolName,
|
||||
event.args,
|
||||
)
|
||||
break
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
store.endToolExecution(
|
||||
event.toolCallId,
|
||||
event.result,
|
||||
event.isError,
|
||||
)
|
||||
break
|
||||
}
|
||||
case "tool_execution_update":
|
||||
// Partial results — not rendered yet, ignored for now
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle error messages from Hub (e.g. UNAUTHORIZED)
|
||||
if (msg.action === "error") {
|
||||
const payload = msg.payload as { code: string; message: string }
|
||||
set({ lastError: { code: payload.code, message: payload.message } })
|
||||
return
|
||||
}
|
||||
|
||||
// Handle direct (non-streaming) messages
|
||||
const payload = msg.payload as { agentId?: string; content?: string }
|
||||
if (payload?.agentId && payload?.content) {
|
||||
useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId)
|
||||
}
|
||||
})
|
||||
.onSendError((error) => set({ lastError: error }))
|
||||
.onVerified((result) => set({ isNewDevice: result.isNewDevice ?? false }))
|
||||
.onError((error) => set({ lastError: { code: "VERIFY_ERROR", message: error.message } }))
|
||||
.onSendError((error) => set({ lastError: { code: error.code, message: error.error } }))
|
||||
}
|
||||
|
||||
/** Fetch message history from Hub via RPC after connection is established */
|
||||
|
|
@ -140,20 +171,55 @@ async function fetchHistory(state: ConnectionStoreState): Promise<void> {
|
|||
if (!client || !hubId || !agentId) return
|
||||
|
||||
try {
|
||||
const result = await client.request<{
|
||||
messages: Array<{ role: string; content: unknown }>
|
||||
total: number
|
||||
}>(hubId, "getAgentMessages", { agentId, limit: 200 })
|
||||
const result = await client.request<GetAgentMessagesResult>(
|
||||
hubId, "getAgentMessages", { agentId, limit: 200 },
|
||||
)
|
||||
|
||||
const messages = result.messages
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((m) => ({
|
||||
id: uuidv7(),
|
||||
role: m.role as "user" | "assistant",
|
||||
content: extractText(m.content),
|
||||
agentId: agentId,
|
||||
}))
|
||||
.filter((m) => m.content.length > 0)
|
||||
// Build a lookup map: toolCallId → { name, arguments } from assistant ToolCall blocks
|
||||
const toolCallArgsMap = new Map<string, { name: string; args: Record<string, unknown> }>()
|
||||
for (const m of result.messages) {
|
||||
if (m.role === "assistant") {
|
||||
for (const block of m.content) {
|
||||
if (block.type === "toolCall") {
|
||||
toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the backend message array directly
|
||||
const messages: Message[] = []
|
||||
for (const m of result.messages) {
|
||||
if (m.role === "user") {
|
||||
messages.push({
|
||||
id: uuidv7(),
|
||||
role: "user",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
})
|
||||
} else if (m.role === "assistant") {
|
||||
messages.push({
|
||||
id: uuidv7(),
|
||||
role: "assistant",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
stopReason: m.stopReason,
|
||||
})
|
||||
} else if (m.role === "toolResult") {
|
||||
const callInfo = toolCallArgsMap.get(m.toolCallId)
|
||||
messages.push({
|
||||
id: uuidv7(),
|
||||
role: "toolResult",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
toolCallId: m.toolCallId,
|
||||
toolName: m.toolName,
|
||||
toolArgs: callInfo?.args,
|
||||
toolStatus: m.isError ? "error" : "success",
|
||||
isError: m.isError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
useMessagesStore.getState().loadMessages(messages)
|
||||
|
|
@ -163,14 +229,22 @@ async function fetchHistory(state: ConnectionStoreState): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Extract plain text from AgentMessage content (string or content block array) */
|
||||
function extractText(content: unknown): string {
|
||||
if (typeof content === "string") return content
|
||||
if (!Array.isArray(content)) return ""
|
||||
return content
|
||||
.filter((c: { type?: string }) => c.type === "text")
|
||||
.map((c: { text?: string }) => c.text ?? "")
|
||||
.join("")
|
||||
/** Convert raw backend content (string or block array) to ContentBlock[] */
|
||||
function toContentBlocks(content: string | ContentBlock[]): ContentBlock[] {
|
||||
if (typeof content === "string") {
|
||||
return content ? [{ type: "text", text: content }] : []
|
||||
}
|
||||
if (Array.isArray(content)) return content
|
||||
return []
|
||||
}
|
||||
|
||||
/** Extract content blocks from an AgentEvent that carries a message */
|
||||
function extractContent(event: AgentEvent): ContentBlock[] {
|
||||
if (!("message" in event)) return []
|
||||
const msg = event.message
|
||||
if (!msg || !("content" in msg)) return []
|
||||
const content = msg.content
|
||||
return Array.isArray(content) ? content as ContentBlock[] : []
|
||||
}
|
||||
|
||||
export const useConnectionStore = create<ConnectionStore>()(
|
||||
|
|
@ -182,6 +256,7 @@ export const useConnectionStore = create<ConnectionStore>()(
|
|||
agentId: null,
|
||||
connectionState: "disconnected",
|
||||
lastError: null,
|
||||
isNewDevice: null,
|
||||
|
||||
// Connect using a connection code (disconnect existing connection first)
|
||||
connect: (code) => {
|
||||
|
|
@ -214,6 +289,7 @@ export const useConnectionStore = create<ConnectionStore>()(
|
|||
hubId: null,
|
||||
agentId: null,
|
||||
lastError: null,
|
||||
isNewDevice: null,
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ export { useConnectionStore } from "./connection-store"
|
|||
export type { ConnectionStore } from "./connection-store"
|
||||
export { useAutoConnect } from "./use-auto-connect"
|
||||
export { useMessagesStore } from "./messages"
|
||||
export type { Message, MessagesStore, SendContext } from "./messages"
|
||||
export type { Message, MessagesStore, SendContext, ToolStatus } from "./messages"
|
||||
export { parseConnectionCode, saveConnection, loadConnection, clearConnection } from "./connection"
|
||||
export type { ConnectionInfo } from "./connection"
|
||||
|
|
|
|||
|
|
@ -1,28 +1,33 @@
|
|||
/**
|
||||
* Messages Store - manages chat messages and streaming state for the current Agent
|
||||
* Messages Store - manages chat messages and streaming state
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Store current Agent's chat messages (replaced on Agent switch, not accumulated)
|
||||
* 2. Manage streaming state (intermediate state while AI replies arrive in chunks)
|
||||
* 3. Provide sendMessage() as the single entry point for sending messages
|
||||
* Data model mirrors the backend (pi-ai / pi-agent-core) exactly:
|
||||
* - UserMessage: { role: "user", content: ContentBlock[] }
|
||||
* - AssistantMessage: { role: "assistant", content: ContentBlock[] }
|
||||
* - ToolResultMessage: { role: "toolResult", toolCallId, toolName, content, isError }
|
||||
*
|
||||
* Send flow:
|
||||
* user input → sendMessage(text)
|
||||
* → addUserMessage() immediately adds to local state (optimistic update)
|
||||
* → ConnectionStore.send() sends to Gateway → Hub → Agent
|
||||
*
|
||||
* Receive flow (driven by ConnectionStore's onMessage callback):
|
||||
* Streaming: startStream → appendStream (repeated) → endStream
|
||||
* Non-streaming: addAssistantMessage (one-shot)
|
||||
* Streaming simply updates the content of the current assistant message in-place.
|
||||
* Tool execution events (start/end) create / update toolResult messages.
|
||||
*/
|
||||
import { create } from "zustand"
|
||||
import { v7 as uuidv7 } from "uuid"
|
||||
import type { ContentBlock } from "@multica/sdk"
|
||||
|
||||
export type ToolStatus = "running" | "success" | "error" | "interrupted"
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
role: "user" | "assistant" | "toolResult"
|
||||
content: ContentBlock[]
|
||||
agentId: string
|
||||
// AssistantMessage metadata
|
||||
stopReason?: string
|
||||
// ToolResult fields (only when role === "toolResult")
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
toolArgs?: Record<string, unknown>
|
||||
toolStatus?: ToolStatus
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
/** Parameters needed to route a message through the gateway */
|
||||
|
|
@ -41,13 +46,16 @@ interface MessagesActions {
|
|||
sendMessage: (text: string, ctx: SendContext) => void
|
||||
addUserMessage: (content: string, agentId: string) => void
|
||||
addAssistantMessage: (content: string, agentId: string) => void
|
||||
updateMessage: (id: string, content: string) => void
|
||||
// Replace all messages (for Agent switch or loading history)
|
||||
updateMessage: (id: string, content: ContentBlock[]) => void
|
||||
loadMessages: (msgs: Message[]) => void
|
||||
clearMessages: () => void
|
||||
// Streaming
|
||||
startStream: (streamId: string, agentId: string) => void
|
||||
appendStream: (streamId: string, content: string) => void
|
||||
endStream: (streamId: string, content: string) => void
|
||||
appendStream: (streamId: string, content: ContentBlock[]) => void
|
||||
endStream: (streamId: string, content: ContentBlock[], stopReason?: string) => void
|
||||
// Tool execution lifecycle
|
||||
startToolExecution: (agentId: string, toolCallId: string, toolName: string, args?: unknown) => void
|
||||
endToolExecution: (toolCallId: string, result?: unknown, isError?: boolean) => void
|
||||
}
|
||||
|
||||
export type MessagesStore = MessagesState & MessagesActions
|
||||
|
|
@ -56,7 +64,6 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
|
|||
messages: [],
|
||||
streamingIds: new Set<string>(),
|
||||
|
||||
// Single entry point for sending: optimistic local add, then send via WebSocket
|
||||
sendMessage: (text, ctx) => {
|
||||
get().addUserMessage(text, ctx.agentId)
|
||||
ctx.send(ctx.hubId, "message", { agentId: ctx.agentId, content: text })
|
||||
|
|
@ -64,13 +71,23 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
|
|||
|
||||
addUserMessage: (content, agentId) => {
|
||||
set((s) => ({
|
||||
messages: [...s.messages, { id: uuidv7(), role: "user", content, agentId }],
|
||||
messages: [...s.messages, {
|
||||
id: uuidv7(),
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: content }],
|
||||
agentId,
|
||||
}],
|
||||
}))
|
||||
},
|
||||
|
||||
addAssistantMessage: (content, agentId) => {
|
||||
set((s) => ({
|
||||
messages: [...s.messages, { id: uuidv7(), role: "assistant", content, agentId }],
|
||||
messages: [...s.messages, {
|
||||
id: uuidv7(),
|
||||
role: "assistant",
|
||||
content: [{ type: "text" as const, text: content }],
|
||||
agentId,
|
||||
}],
|
||||
}))
|
||||
},
|
||||
|
||||
|
|
@ -80,7 +97,6 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
|
|||
}))
|
||||
},
|
||||
|
||||
// Replace all messages (for Agent switch or loading history)
|
||||
loadMessages: (msgs) => {
|
||||
set({ messages: msgs })
|
||||
},
|
||||
|
|
@ -89,35 +105,79 @@ export const useMessagesStore = create<MessagesStore>()((set, get) => ({
|
|||
set({ messages: [], streamingIds: new Set() })
|
||||
},
|
||||
|
||||
// === The following three methods are called by ConnectionStore's onMessage callback ===
|
||||
// Stream start: create an empty placeholder message and mark as streaming
|
||||
// --- Streaming: build assistant message incrementally ---
|
||||
|
||||
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 }],
|
||||
messages: [...s.messages, { id: streamId, role: "assistant" as const, content: [], agentId }],
|
||||
streamingIds: ids,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// Stream update: replace message content (each update carries the full accumulated text)
|
||||
// Replace the entire content array with the latest partial snapshot
|
||||
appendStream: (streamId, content) => {
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)),
|
||||
}))
|
||||
},
|
||||
|
||||
// Stream end: write final content, remove streaming marker
|
||||
endStream: (streamId, content) => {
|
||||
endStream: (streamId, content, stopReason) => {
|
||||
set((s) => {
|
||||
const ids = new Set(s.streamingIds)
|
||||
ids.delete(streamId)
|
||||
// Find the agentId of the stream being ended to scope tool interruption
|
||||
const streamMsg = s.messages.find((m) => m.id === streamId)
|
||||
const streamAgentId = streamMsg?.agentId
|
||||
return {
|
||||
messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)),
|
||||
messages: s.messages.map((m) => {
|
||||
if (m.id === streamId) return { ...m, content, stopReason }
|
||||
// Interrupt running tool executions belonging to the same agent
|
||||
if (m.role === "toolResult" && m.toolStatus === "running" && m.agentId === streamAgentId) {
|
||||
return { ...m, toolStatus: "interrupted" as ToolStatus }
|
||||
}
|
||||
return m
|
||||
}),
|
||||
streamingIds: ids,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// --- Tool execution: create / update toolResult messages ---
|
||||
|
||||
startToolExecution: (agentId, toolCallId, toolName, args) => {
|
||||
set((s) => ({
|
||||
messages: [...s.messages, {
|
||||
id: uuidv7(),
|
||||
role: "toolResult" as const,
|
||||
content: [],
|
||||
agentId,
|
||||
toolCallId,
|
||||
toolName,
|
||||
toolArgs: args as Record<string, unknown> | undefined,
|
||||
toolStatus: "running" as ToolStatus,
|
||||
isError: false,
|
||||
}],
|
||||
}))
|
||||
},
|
||||
|
||||
endToolExecution: (toolCallId, result, isError) => {
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) =>
|
||||
m.role === "toolResult" && m.toolCallId === toolCallId
|
||||
? {
|
||||
...m,
|
||||
toolStatus: (isError ? "error" : "success") as ToolStatus,
|
||||
isError: isError ?? false,
|
||||
content: result != null
|
||||
? [{ type: "text" as const, text: typeof result === "string" ? result : JSON.stringify(result) }]
|
||||
: [],
|
||||
}
|
||||
: m
|
||||
),
|
||||
}))
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": ["**/*.css"],
|
||||
"exports": {
|
||||
"./globals.css": "./src/styles/globals.css",
|
||||
"./postcss.config": "./postcss.config.mjs",
|
||||
|
|
@ -18,6 +19,10 @@
|
|||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"@multica/store": "workspace:*",
|
||||
"@tiptap/extension-placeholder": "^3.19.0",
|
||||
"@tiptap/pm": "^3.19.0",
|
||||
"@tiptap/react": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"linkify-it": "^5.0.0",
|
||||
|
|
|
|||
18
packages/ui/src/components/chat-input.css
Normal file
18
packages/ui/src/components/chat-input.css
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
.chat-input-editor .ProseMirror {
|
||||
outline: none;
|
||||
min-height: 2.5rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-input-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chat-input-editor.is-disabled .ProseMirror {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -1,9 +1,20 @@
|
|||
"use client";
|
||||
import { useRef } from "react";
|
||||
import { useRef, useEffect, useImperativeHandle, forwardRef } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ArrowUpIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import "./chat-input.css";
|
||||
|
||||
export interface ChatInputRef {
|
||||
getText: () => string;
|
||||
setText: (text: string) => void;
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSubmit?: (value: string) => void;
|
||||
|
|
@ -11,45 +22,105 @@ interface ChatInputProps {
|
|||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSubmit, disabled, placeholder = "Type a message..." }: ChatInputProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
function ChatInput({ onSubmit, disabled, placeholder = "Type a message..." }, ref) {
|
||||
// Use ref to avoid stale closure in Tiptap keydown handler
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const value = textareaRef.current?.value ?? "";
|
||||
if (!value.trim()) return;
|
||||
onSubmit?.(value);
|
||||
textareaRef.current!.value = "";
|
||||
// reset height
|
||||
textareaRef.current!.style.height = "auto";
|
||||
};
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
// Disable all rich-text features — plain text only
|
||||
heading: false,
|
||||
bold: false,
|
||||
italic: false,
|
||||
strike: false,
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
blockquote: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
horizontalRule: false,
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"w-full resize-none bg-transparent px-1 py-1 text-base text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
|
||||
},
|
||||
handleKeyDown(_view, event) {
|
||||
// Guard for IME composition (Chinese/Japanese input)
|
||||
if (event.isComposing) return false;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"bg-card rounded-xl p-3 border border-border transition-colors",
|
||||
disabled && "cursor-not-allowed opacity-60"
|
||||
)}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const text = _view.state.doc.textContent;
|
||||
if (!text.trim()) return true;
|
||||
onSubmitRef.current?.(text);
|
||||
// Clear editor after submit
|
||||
_view.dispatch(
|
||||
_view.state.tr
|
||||
.delete(0, _view.state.doc.content.size)
|
||||
.setMeta("addToHistory", false),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}}
|
||||
className="w-full resize-none bg-transparent px-1 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button size="icon" onClick={handleSubmit} disabled={disabled}>
|
||||
<HugeiconsIcon strokeWidth={2.5} icon={ArrowUpIcon} />
|
||||
</Button>
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sync disabled state
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.setEditable(!disabled);
|
||||
}, [editor, disabled]);
|
||||
|
||||
// Sync placeholder
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "placeholder",
|
||||
)!.options.placeholder = placeholder;
|
||||
// Force view update so placeholder re-renders
|
||||
editor.view.dispatch(editor.state.tr);
|
||||
}, [editor, placeholder]);
|
||||
|
||||
// Expose imperative API
|
||||
useImperativeHandle(ref, () => ({
|
||||
getText: () => editor?.state.doc.textContent ?? "",
|
||||
setText: (text: string) => {
|
||||
editor?.commands.setContent(text ? `<p>${text}</p>` : "");
|
||||
},
|
||||
focus: () => editor?.commands.focus(),
|
||||
clear: () => editor?.commands.clearContent(),
|
||||
}), [editor]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!editor) return;
|
||||
const text = editor.state.doc.textContent;
|
||||
if (!text.trim()) return;
|
||||
onSubmit?.(text);
|
||||
editor.commands.clearContent();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"chat-input-editor bg-card rounded-xl p-3 border border-border transition-colors",
|
||||
disabled && "is-disabled cursor-not-allowed opacity-60",
|
||||
)}>
|
||||
<EditorContent editor={editor} />
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button size="icon-lg" onClick={handleSubmit} disabled={disabled}>
|
||||
<HugeiconsIcon strokeWidth={2.5} icon={ArrowUpIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useCallback } from "react";
|
||||
import { useRef, useCallback, useState, useEffect } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ChatInput } from "@multica/ui/components/chat-input";
|
||||
import { useConnectionStore, useMessagesStore, useAutoConnect } from "@multica/store";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
|
||||
import { ConnectPrompt } from "./connect-prompt";
|
||||
import { MessageList } from "./message-list";
|
||||
import { ChatSkeleton } from "./chat-skeleton";
|
||||
|
|
@ -16,10 +19,26 @@ export function Chat() {
|
|||
const agentId = useConnectionStore((s) => s.agentId)
|
||||
const gwState = useConnectionStore((s) => s.connectionState)
|
||||
const hubId = useConnectionStore((s) => s.hubId)
|
||||
const lastError = useConnectionStore((s) => s.lastError)
|
||||
const isNewDevice = useConnectionStore((s) => s.isNewDevice)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const messages = useMessagesStore((s) => s.messages)
|
||||
const streamingIds = useMessagesStore((s) => s.streamingIds)
|
||||
|
||||
// Show success overlay for 2s when a new device is approved by Owner
|
||||
const [showVerifySuccess, setShowVerifySuccess] = useState(false)
|
||||
useEffect(() => {
|
||||
if (gwState === "registered" && isNewDevice === true) {
|
||||
setShowVerifySuccess(true)
|
||||
const timer = setTimeout(() => {
|
||||
setShowVerifySuccess(false)
|
||||
useConnectionStore.setState({ isNewDevice: null })
|
||||
}, 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [gwState, isNewDevice])
|
||||
|
||||
const isConnected = gwState === "registered" && !!hubId && !!agentId
|
||||
|
||||
const handleSend = useCallback((text: string) => {
|
||||
|
|
@ -38,6 +57,26 @@ export function Chat() {
|
|||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
{/* Verify success overlay — shown for 2s when new device approved */}
|
||||
{showVerifySuccess && (
|
||||
<div className={
|
||||
isMobile
|
||||
? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6 animate-in fade-in duration-300"
|
||||
: "absolute inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6 animate-in fade-in duration-300"
|
||||
}>
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
className="size-14 text-(--tool-success) animate-in zoom-in duration-300"
|
||||
/>
|
||||
<div className="text-center space-y-1.5">
|
||||
<p className="text-base font-medium">Connected</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your device has been approved
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && (
|
||||
<div className="flex items-center justify-end px-4 py-1 max-w-4xl mx-auto w-full">
|
||||
<Button
|
||||
|
|
@ -58,19 +97,36 @@ export function Chat() {
|
|||
<ConnectPrompt />
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Send a message to start the conversation
|
||||
Your Agent is ready
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} streamingIds={streamingIds} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Error banner */}
|
||||
{lastError && (
|
||||
<div className="px-4 py-2 max-w-4xl mx-auto w-full" role="alert" aria-live="polite">
|
||||
<div className="rounded-md bg-destructive/10 text-destructive text-sm px-3 py-2 flex items-center justify-between">
|
||||
<span>{lastError.message} ({lastError.code})</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss error"
|
||||
onClick={() => useConnectionStore.setState({ lastError: null })}
|
||||
className="text-destructive/60 hover:text-destructive ml-2 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded outline-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
|
||||
<ChatInput
|
||||
onSubmit={handleSend}
|
||||
disabled={!isConnected}
|
||||
placeholder={!isConnected ? "Connect first..." : "Type a message..."}
|
||||
placeholder={!isConnected ? "Scan QR code to get started" : "Ask your Agent..."}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,163 +1,267 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, lazy, Suspense, useRef } from "react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import { toast } from "@multica/ui/components/ui/sonner";
|
||||
import {
|
||||
useConnectionStore,
|
||||
parseConnectionCode,
|
||||
saveConnection,
|
||||
} from "@multica/store";
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Camera01Icon, TextIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
const LazyQrScannerView = lazy(() =>
|
||||
import("@multica/ui/components/qr-scanner-view").then((m) => ({
|
||||
default: m.QrScannerView,
|
||||
})),
|
||||
);
|
||||
import {
|
||||
Camera01Icon,
|
||||
TextIcon,
|
||||
CheckmarkCircle02Icon,
|
||||
Alert02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { QrScannerView } from "@multica/ui/components/qr-scanner-view";
|
||||
import { Spinner } from "@multica/ui/components/spinner";
|
||||
|
||||
type Mode = "scan" | "paste";
|
||||
type PasteState = "idle" | "success" | "error";
|
||||
|
||||
/** Shown while connecting to Gateway or waiting for Owner approval */
|
||||
function ConnectionStatus({ fullscreen }: { fullscreen?: boolean }) {
|
||||
const gwState = useConnectionStore((s) => s.connectionState);
|
||||
const disconnect = useConnectionStore((s) => s.disconnect);
|
||||
const isVerifying = gwState === "verifying";
|
||||
|
||||
const wrapper = fullscreen
|
||||
? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6"
|
||||
: "flex flex-col items-center justify-center h-full gap-5 px-4";
|
||||
|
||||
return (
|
||||
<div className={wrapper}>
|
||||
<Spinner className="text-muted-foreground text-sm" />
|
||||
<div className="text-center space-y-1.5">
|
||||
<p className="text-base font-medium">
|
||||
{isVerifying ? "Waiting for approval" : "Connecting..."}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground max-w-[260px]">
|
||||
{isVerifying
|
||||
? "The device owner needs to approve this connection on their computer"
|
||||
: "Establishing connection to the agent"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={disconnect}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown when Owner rejects the connection, auto-dismisses after 2s */
|
||||
function RejectedStatus({ fullscreen, onDismiss }: { fullscreen?: boolean; onDismiss: () => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDismiss, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onDismiss]);
|
||||
|
||||
const wrapper = fullscreen
|
||||
? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6"
|
||||
: "flex flex-col items-center justify-center h-full gap-5 px-4";
|
||||
|
||||
return (
|
||||
<div className={wrapper}>
|
||||
<HugeiconsIcon
|
||||
icon={Alert02Icon}
|
||||
className="size-14 text-destructive animate-in zoom-in duration-300"
|
||||
/>
|
||||
<div className="text-center space-y-1.5">
|
||||
<p className="text-base font-medium">Connection rejected</p>
|
||||
<p className="text-xs text-muted-foreground max-w-[260px]">
|
||||
The device owner declined this connection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectPrompt() {
|
||||
const gwState = useConnectionStore((s) => s.connectionState);
|
||||
const lastError = useConnectionStore((s) => s.lastError);
|
||||
const [mode, setMode] = useState<Mode>("scan");
|
||||
const [codeInput, setCodeInput] = useState("");
|
||||
const [mode, setMode] = useState<Mode>("paste"); // SSR-safe default
|
||||
const [canScan, setCanScan] = useState(false);
|
||||
const scannedRef = useRef(false);
|
||||
const [pasteState, setPasteState] = useState<PasteState>("idle");
|
||||
const [pasteError, setPasteError] = useState<string | null>(null);
|
||||
const [showRejected, setShowRejected] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const validatingRef = useRef(false);
|
||||
|
||||
// Detect mobile + camera capability, auto-switch to scan mode
|
||||
// Detect verify rejection: lastError appears while disconnected
|
||||
useEffect(() => {
|
||||
const isTouchDevice =
|
||||
"ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
const isNarrow = window.innerWidth < 768;
|
||||
const hasGetUserMedia = !!navigator.mediaDevices?.getUserMedia;
|
||||
|
||||
if (hasGetUserMedia) {
|
||||
setCanScan(true);
|
||||
if (isTouchDevice && isNarrow) {
|
||||
setMode("scan");
|
||||
}
|
||||
if (lastError?.code === "VERIFY_ERROR" && gwState === "disconnected") {
|
||||
setShowRejected(true);
|
||||
}
|
||||
}, [lastError, gwState]);
|
||||
|
||||
const handleDismissRejected = useCallback(() => {
|
||||
setShowRejected(false);
|
||||
useConnectionStore.setState({ lastError: null });
|
||||
}, []);
|
||||
|
||||
// Handle paste-mode connect
|
||||
const handleConnect = useCallback(() => {
|
||||
const trimmed = codeInput.trim();
|
||||
if (!trimmed) return;
|
||||
const tryConnect = useCallback((raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || validatingRef.current) return;
|
||||
validatingRef.current = true;
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed);
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
setCodeInput("");
|
||||
setPasteState("success");
|
||||
navigator.vibrate?.(50);
|
||||
// Let the user see the success state before connecting
|
||||
setTimeout(() => {
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
}, 600);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}, [codeInput]);
|
||||
|
||||
// Handle QR scan result — auto-connect, no button needed
|
||||
const handleQrScan = useCallback((data: string) => {
|
||||
// Prevent duplicate connects from rapid successive scans
|
||||
if (scannedRef.current) return;
|
||||
scannedRef.current = true;
|
||||
|
||||
try {
|
||||
const info = parseConnectionCode(data);
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
// Allow re-scan on error (invalid/expired code)
|
||||
scannedRef.current = false;
|
||||
setPasteState("error");
|
||||
setPasteError((e as Error).message || "Invalid code");
|
||||
navigator.vibrate?.([30, 50, 30]);
|
||||
setTimeout(() => {
|
||||
setPasteState("idle");
|
||||
setPasteError(null);
|
||||
setCodeInput("");
|
||||
}, 2000);
|
||||
} finally {
|
||||
validatingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleScanError = useCallback((msg: string) => {
|
||||
toast.error(msg);
|
||||
setMode("paste");
|
||||
// Auto-validate on paste
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
const text = e.clipboardData.getData("text");
|
||||
if (!text.trim()) return;
|
||||
// Let the textarea update visually first, then validate
|
||||
setTimeout(() => tryConnect(text), 50);
|
||||
},
|
||||
[tryConnect],
|
||||
);
|
||||
|
||||
// Promise-based handler for QrScannerView
|
||||
const handleScanResult = useCallback(async (data: string) => {
|
||||
const info = parseConnectionCode(data);
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
}, []);
|
||||
|
||||
const isConnecting = gwState === "connecting" || gwState === "connected";
|
||||
const isInProgress =
|
||||
gwState === "connecting" ||
|
||||
gwState === "connected" ||
|
||||
gwState === "verifying";
|
||||
|
||||
// Verification rejected — show rejection feedback
|
||||
if (showRejected) {
|
||||
return <RejectedStatus fullscreen={isMobile} onDismiss={handleDismissRejected} />;
|
||||
}
|
||||
|
||||
// Connection in progress — show status (replaces scanner/paste)
|
||||
if (isInProgress) {
|
||||
return <ConnectionStatus fullscreen={isMobile} />;
|
||||
}
|
||||
|
||||
// Mobile: scanner only, no tabs, no paste
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-base font-medium">Scan to start</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scan a Multica QR code to start chatting
|
||||
</p>
|
||||
</div>
|
||||
<QrScannerView onResult={handleScanResult} fullscreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: tab toggle (scan / paste), same-size panels
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mode === "scan"
|
||||
? "Scan QR code to connect"
|
||||
: "Paste a connection code to start"}
|
||||
<p className="text-base font-medium">
|
||||
{mode === "scan" ? "Scan to start" : "Paste to start"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{mode === "scan"
|
||||
? "Scan a Multica QR code to start chatting"
|
||||
: "Paste a Multica connection code to start chatting"}
|
||||
</p>
|
||||
{isConnecting && (
|
||||
<p className="text-xs text-muted-foreground/60 animate-pulse">
|
||||
Connecting...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode toggle — only show if camera is available */}
|
||||
{canScan && (
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-1">
|
||||
<Button
|
||||
variant={mode === "scan" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="text-xs gap-1.5 h-7 px-3"
|
||||
onClick={() => {
|
||||
scannedRef.current = false;
|
||||
setMode("scan");
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" />
|
||||
Scan
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "paste" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="text-xs gap-1.5 h-7 px-3"
|
||||
onClick={() => setMode("paste")}
|
||||
>
|
||||
<HugeiconsIcon icon={TextIcon} className="size-3.5" />
|
||||
Paste
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Mode toggle */}
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-1">
|
||||
<Button
|
||||
variant={mode === "scan" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="text-xs gap-1.5 h-7 px-3"
|
||||
onClick={() => setMode("scan")}
|
||||
>
|
||||
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" />
|
||||
Scan
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "paste" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="text-xs gap-1.5 h-7 px-3"
|
||||
onClick={() => setMode("paste")}
|
||||
>
|
||||
<HugeiconsIcon icon={TextIcon} className="size-3.5" />
|
||||
Paste
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
{/* Content — same max-width for both modes */}
|
||||
<div className="w-full max-w-[320px]">
|
||||
{mode === "scan" ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-[280px] animate-pulse bg-muted rounded-xl" />
|
||||
}
|
||||
>
|
||||
<LazyQrScannerView
|
||||
onScan={handleQrScan}
|
||||
onError={handleScanError}
|
||||
/>
|
||||
</Suspense>
|
||||
<QrScannerView onResult={handleScanResult} />
|
||||
) : (
|
||||
<>
|
||||
<Textarea
|
||||
value={codeInput}
|
||||
onChange={(e) => setCodeInput(e.target.value)}
|
||||
placeholder="Paste connection code here..."
|
||||
className="text-xs font-mono min-h-[100px] resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleConnect();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={!codeInput.trim() || gwState === "connecting"}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</>
|
||||
<div className="aspect-square rounded-xl bg-muted flex flex-col items-center justify-center p-4">
|
||||
{pasteState === "idle" && (
|
||||
<Textarea
|
||||
value={codeInput}
|
||||
onChange={(e) => setCodeInput(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
autoFocus={true}
|
||||
placeholder="Paste connection code here..."
|
||||
className="text-xs font-mono flex-1 resize-none bg-transparent! border-0 focus-visible:ring-0 shadow-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
tryConnect(codeInput);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pasteState === "success" && (
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
className="size-14 text-(--tool-success) animate-in zoom-in duration-300"
|
||||
/>
|
||||
)}
|
||||
|
||||
{pasteState === "error" && (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={Alert02Icon}
|
||||
className="size-12 text-(--tool-error)"
|
||||
/>
|
||||
{pasteError && (
|
||||
<p className="text-xs text-destructive bg-destructive/10 px-3 py-1.5 rounded-full">
|
||||
{pasteError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ export function Markdown({
|
|||
const processedContent = React.useMemo(() => preprocessLinks(children), [children])
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-content text-sm', className)}>
|
||||
<div className={cn('markdown-content', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,102 @@
|
|||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
|
||||
import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ToolCallItem } from "@multica/ui/components/tool-call-item";
|
||||
import { cn, getTextContent } from "@multica/ui/lib/utils";
|
||||
import type { Message } from "@multica/store";
|
||||
import type { ContentBlock, ToolCall } from "@multica/sdk";
|
||||
|
||||
/** Extract toolCall blocks from content */
|
||||
function getToolCalls(blocks: ContentBlock[]): ToolCall[] {
|
||||
return blocks.filter((b): b is ToolCall => b.type === "toolCall")
|
||||
}
|
||||
|
||||
/** Build a synthetic "running" toolResult Message from a ToolCall block */
|
||||
function toRunningMessage(tc: ToolCall, agentId: string): Message {
|
||||
return {
|
||||
id: tc.id,
|
||||
role: "toolResult",
|
||||
content: [],
|
||||
agentId,
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
toolArgs: tc.arguments,
|
||||
toolStatus: "running",
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[]
|
||||
streamingIds: Set<string>
|
||||
}
|
||||
|
||||
export function MessageList({ messages, streamingIds }: MessageListProps) {
|
||||
export const MessageList = memo(function MessageList({ messages, streamingIds }: MessageListProps) {
|
||||
// Build a set of toolCallIds that already have a toolResult message,
|
||||
// so we don't render duplicate items from the assistant's toolCall blocks
|
||||
const resolvedToolCallIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "toolResult" && msg.toolCallId) {
|
||||
ids.add(msg.toolCallId)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [messages])
|
||||
|
||||
return (
|
||||
<div className="relative px-4 py-6 space-y-6 max-w-4xl mx-auto">
|
||||
<div className="relative px-4 py-6 max-w-4xl mx-auto">
|
||||
{messages.map((msg) => {
|
||||
// ToolResult messages → render as tool execution item
|
||||
if (msg.role === "toolResult") {
|
||||
return <ToolCallItem key={msg.id} message={msg} />
|
||||
}
|
||||
|
||||
const text = getTextContent(msg.content)
|
||||
const toolCalls = msg.role === "assistant" ? getToolCalls(msg.content) : []
|
||||
const isStreaming = streamingIds.has(msg.id)
|
||||
|
||||
// Find toolCall blocks that don't have a toolResult message yet —
|
||||
// these are tools the LLM decided to call but haven't started executing
|
||||
const unresolvedToolCalls = toolCalls.filter((tc) => !resolvedToolCallIds.has(tc.id))
|
||||
|
||||
// Skip completely empty messages (no text, no unresolved tools, not streaming)
|
||||
if (!text && unresolvedToolCalls.length === 0 && !isStreaming) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"flex",
|
||||
msg.role === "user" ? "justify-end" : "justify-start"
|
||||
<div key={msg.id}>
|
||||
{/* Render text content (if any) */}
|
||||
{(text || isStreaming) && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
msg.role === "user" ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] py-1 px-2.5 my-2" : "w-full py-1 px-2.5 my-1"
|
||||
)}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<StreamingMarkdown content={text} isStreaming={true} mode="minimal" />
|
||||
) : (
|
||||
<MemoizedMarkdown mode="minimal" id={msg.id}>
|
||||
{text}
|
||||
</MemoizedMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Render unresolved toolCall blocks as "running" tool items */}
|
||||
{unresolvedToolCalls.map((tc) => (
|
||||
<ToolCallItem key={tc.id} message={toRunningMessage(tc, msg.agentId)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
40
packages/ui/src/components/qr-scanner-sheet.tsx
Normal file
40
packages/ui/src/components/qr-scanner-sheet.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@multica/ui/components/ui/sheet"
|
||||
import { QrScannerView } from "@multica/ui/components/qr-scanner-view"
|
||||
|
||||
export interface QrScannerSheetProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onResult: (data: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function QrScannerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
onResult,
|
||||
}: QrScannerSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="bottom" className="px-4 pb-8">
|
||||
{/* Drag handle */}
|
||||
<div className="mx-auto mt-2 mb-1 h-1 w-10 rounded-full bg-muted-foreground/30" />
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-center">Scan Connection Code</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-4">
|
||||
<QrScannerView
|
||||
open={open}
|
||||
onResult={onResult}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,49 +1,191 @@
|
|||
"use client"
|
||||
|
||||
import { useQrScanner } from "@multica/ui/hooks/use-qr-scanner"
|
||||
import "./qr-scanner.css"
|
||||
|
||||
interface QrScannerViewProps {
|
||||
onScan: (data: string) => void
|
||||
onError?: (error: string) => void
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useQrScanner } from "@multica/ui/hooks/use-qr-scanner"
|
||||
import { Spinner } from "@multica/ui/components/spinner"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
Camera01Icon,
|
||||
Cancel01Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
Alert02Icon,
|
||||
FlashlightIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
|
||||
type ScannerState =
|
||||
| "idle"
|
||||
| "requesting"
|
||||
| "scanning"
|
||||
| "detected"
|
||||
| "success"
|
||||
| "error"
|
||||
|
||||
export interface QrScannerProps {
|
||||
onResult: (data: string) => Promise<void>
|
||||
onClose?: () => void
|
||||
open?: boolean
|
||||
/** When true, scanning state renders as a fullscreen overlay (mobile). */
|
||||
fullscreen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera viewfinder for QR code scanning.
|
||||
*
|
||||
* Renders a live camera feed with a decorative scan frame overlay.
|
||||
* Uses getUserMedia via the qr-scanner library (WebWorker-based decoding).
|
||||
* iOS requires playsinline + muted + autoplay on the <video> element.
|
||||
*/
|
||||
export function QrScannerView({ onScan, onError }: QrScannerViewProps) {
|
||||
const { videoRef, isScanning, error, hasCamera } = useQrScanner({
|
||||
onScan,
|
||||
onError,
|
||||
enabled: true,
|
||||
const ACTIVE_STATES: ScannerState[] = [
|
||||
"requesting",
|
||||
"scanning",
|
||||
"detected",
|
||||
"success",
|
||||
"error",
|
||||
]
|
||||
|
||||
export function QrScannerView({
|
||||
onResult,
|
||||
onClose,
|
||||
open,
|
||||
fullscreen = false,
|
||||
}: QrScannerProps) {
|
||||
const [state, setState] = useState<ScannerState>("idle")
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
const startRef = useRef<(() => Promise<void>) | null>(null)
|
||||
|
||||
const handleScan = useCallback(
|
||||
(data: string) => {
|
||||
if (stateRef.current !== "scanning") return
|
||||
|
||||
setState("detected")
|
||||
navigator.vibrate?.(50)
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await onResult(data)
|
||||
setState("success")
|
||||
navigator.vibrate?.(50)
|
||||
} catch (e) {
|
||||
setErrorMessage((e as Error).message || "Invalid code")
|
||||
setState("error")
|
||||
navigator.vibrate?.([30, 50, 30])
|
||||
setTimeout(() => {
|
||||
setErrorMessage(null)
|
||||
setState("scanning")
|
||||
startRef.current?.()
|
||||
}, 3000)
|
||||
}
|
||||
}, 200)
|
||||
},
|
||||
[onResult],
|
||||
)
|
||||
|
||||
const {
|
||||
videoRef,
|
||||
hasCamera,
|
||||
hasFlash,
|
||||
toggleFlash,
|
||||
start: scannerStart,
|
||||
stop: scannerStop,
|
||||
pause: scannerPause,
|
||||
} = useQrScanner({
|
||||
onScan: handleScan,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
startRef.current = scannerStart
|
||||
|
||||
useEffect(() => {
|
||||
if (state === "detected" || state === "success") {
|
||||
scannerPause()
|
||||
}
|
||||
}, [state, scannerPause])
|
||||
|
||||
useEffect(() => {
|
||||
if (open === false) {
|
||||
scannerStop()
|
||||
setState("idle")
|
||||
setErrorMessage(null)
|
||||
}
|
||||
}, [open, scannerStop])
|
||||
|
||||
useEffect(() => {
|
||||
return () => scannerStop()
|
||||
}, [scannerStop])
|
||||
|
||||
// Double-rAF: wait for video element to mount before starting scanner
|
||||
useEffect(() => {
|
||||
if (state !== "requesting") return
|
||||
const raf = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
try {
|
||||
await scannerStart()
|
||||
setState("scanning")
|
||||
} catch {
|
||||
setState("idle")
|
||||
}
|
||||
})
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [state, scannerStart])
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
try {
|
||||
const perm = await navigator.permissions?.query({
|
||||
name: "camera" as PermissionName,
|
||||
})
|
||||
if (perm?.state === "denied") {
|
||||
setErrorMessage(
|
||||
"Camera access denied. Please enable it in your browser settings.",
|
||||
)
|
||||
onClose?.()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Safari doesn't support camera permission query
|
||||
}
|
||||
setState("requesting")
|
||||
}, [onClose])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
scannerStop()
|
||||
setState("idle")
|
||||
setErrorMessage(null)
|
||||
}, [scannerStop])
|
||||
|
||||
if (!hasCamera) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[280px] rounded-xl bg-muted">
|
||||
<div className="flex items-center justify-center h-[320px] rounded-xl bg-muted">
|
||||
<p className="text-sm text-muted-foreground">No camera available</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[280px] rounded-xl bg-muted gap-2">
|
||||
<p className="text-sm text-muted-foreground">Camera access denied</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Switch to paste mode below
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const isActive = ACTIVE_STATES.includes(state)
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[280px] mx-auto">
|
||||
{/* Camera feed */}
|
||||
<div className="relative aspect-square rounded-xl overflow-hidden bg-black">
|
||||
const bracketColor =
|
||||
state === "success"
|
||||
? "border-[color:var(--tool-success)]"
|
||||
: state === "error"
|
||||
? "border-[color:var(--tool-error)]"
|
||||
: state === "detected"
|
||||
? "border-primary"
|
||||
: "border-white/30"
|
||||
|
||||
const bracketAnimation =
|
||||
state === "scanning"
|
||||
? "animate-scan-breathe"
|
||||
: state === "error"
|
||||
? "animate-scan-shake"
|
||||
: ""
|
||||
|
||||
const viewfinder = (
|
||||
<div
|
||||
className={
|
||||
fullscreen && isActive
|
||||
? "relative w-full h-full"
|
||||
: "relative aspect-square rounded-xl overflow-hidden bg-muted"
|
||||
}
|
||||
>
|
||||
{/* Video — only mounted after idle */}
|
||||
{state !== "idle" && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
|
|
@ -51,32 +193,140 @@ export function QrScannerView({ onScan, onError }: QrScannerViewProps) {
|
|||
muted
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scan frame overlay */}
|
||||
{/* Idle */}
|
||||
{state === "idle" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStart}
|
||||
className="flex items-center justify-center size-16 rounded-full bg-foreground/10 hover:bg-foreground/20 transition-colors"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Camera01Icon}
|
||||
className="size-7 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">Tap to open camera</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requesting */}
|
||||
{state === "requesting" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
|
||||
<Spinner className="text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Requesting camera...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fixed centered brackets — always same position, color changes per state */}
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="relative w-3/4 h-3/4">
|
||||
{/* Corner accents */}
|
||||
<div className="absolute -top-1 -left-1 w-5 h-5 border-t-2 border-l-2 border-white/70 rounded-tl-md" />
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 border-t-2 border-r-2 border-white/70 rounded-tr-md" />
|
||||
<div className="absolute -bottom-1 -left-1 w-5 h-5 border-b-2 border-l-2 border-white/70 rounded-bl-md" />
|
||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 border-b-2 border-r-2 border-white/70 rounded-br-md" />
|
||||
<div
|
||||
className={`relative w-3/4 h-3/4 max-w-[280px] max-h-[280px] ${bracketAnimation}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 ${bracketColor} rounded-tl-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 ${bracketColor} rounded-tr-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 ${bracketColor} rounded-bl-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 ${bracketColor} rounded-br-md transition-colors duration-200`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{!isScanning && !error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<p className="text-xs text-white/80 animate-pulse">
|
||||
Starting camera...
|
||||
|
||||
{/* Close button */}
|
||||
{(state === "scanning" || state === "detected") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-3 left-3 flex items-center justify-center size-8 rounded-full bg-black/40 hover:bg-black/60 transition-colors z-10"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
className="size-4 text-white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Flash toggle */}
|
||||
{state === "scanning" && hasFlash && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFlash}
|
||||
className="absolute top-3 right-3 flex items-center justify-center size-8 rounded-full bg-black/40 hover:bg-black/60 transition-colors"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={FlashlightIcon}
|
||||
className="size-4 text-white"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Success — full overlay */}
|
||||
{state === "success" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--tool-success)]/15 animate-in fade-in duration-200">
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
className="size-14 text-[color:var(--tool-success)] animate-in zoom-in duration-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error — full overlay */}
|
||||
{state === "error" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-[color:var(--tool-error)]/15 animate-in fade-in duration-200">
|
||||
<HugeiconsIcon
|
||||
icon={Alert02Icon}
|
||||
className="size-12 text-[color:var(--tool-error)]"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-xs text-white bg-black/60 px-3 py-1.5 rounded-full">
|
||||
{errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint text */}
|
||||
<p className="text-xs text-muted-foreground text-center mt-3">
|
||||
Point camera at QR code on desktop
|
||||
</p>
|
||||
{/* Fullscreen hint */}
|
||||
{state === "scanning" && fullscreen && (
|
||||
<p className="absolute bottom-8 inset-x-0 text-xs text-white/60 text-center">
|
||||
Align QR code within the frame
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (fullscreen && isActive) {
|
||||
return (
|
||||
<>
|
||||
<div className="relative w-full max-w-[320px] mx-auto">
|
||||
<div className="aspect-square rounded-xl bg-muted" />
|
||||
</div>
|
||||
<div className="fixed inset-0 z-50 bg-black">{viewfinder}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[320px] mx-auto">
|
||||
{viewfinder}
|
||||
{state === "scanning" && !fullscreen && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-3">
|
||||
Point at a Multica QR code
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
29
packages/ui/src/components/qr-scanner.css
Normal file
29
packages/ui/src/components/qr-scanner.css
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/* Scanner corner bracket breathing pulse */
|
||||
@keyframes scan-breathe {
|
||||
0%, 100% { opacity: 0.5; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
/* Error shake */
|
||||
@keyframes scan-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-4px); }
|
||||
40% { transform: translateX(4px); }
|
||||
60% { transform: translateX(-3px); }
|
||||
80% { transform: translateX(2px); }
|
||||
}
|
||||
|
||||
@utility animate-scan-breathe {
|
||||
animation: scan-breathe 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@utility animate-scan-shake {
|
||||
animation: scan-shake 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-scan-breathe,
|
||||
.animate-scan-shake {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
224
packages/ui/src/components/tool-call-item.tsx
Normal file
224
packages/ui/src/components/tool-call-item.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useState } from "react"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
File01Icon,
|
||||
FloppyDiskIcon,
|
||||
FileEditIcon,
|
||||
CommandLineIcon,
|
||||
Search01Icon,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
DatabaseIcon,
|
||||
GitBranchIcon,
|
||||
ArrowRight01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { cn, getTextContent } from "@multica/ui/lib/utils"
|
||||
import type { Message } from "@multica/store"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool display config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOOL_DISPLAY: Record<string, { label: string; icon: typeof File01Icon }> = {
|
||||
read: { label: "Read", icon: File01Icon },
|
||||
write: { label: "Write", icon: FloppyDiskIcon },
|
||||
edit: { label: "Edit", icon: FileEditIcon },
|
||||
exec: { label: "Exec", icon: CommandLineIcon },
|
||||
bash: { label: "Exec", icon: CommandLineIcon },
|
||||
process: { label: "Process", icon: CommandLineIcon },
|
||||
grep: { label: "Grep", icon: Search01Icon },
|
||||
find: { label: "Find", icon: Search01Icon },
|
||||
ls: { label: "ListDir", icon: FolderOpenIcon },
|
||||
glob: { label: "Glob", icon: Search01Icon },
|
||||
web_search: { label: "WebSearch", icon: GlobeIcon },
|
||||
web_fetch: { label: "WebFetch", icon: GlobeIcon },
|
||||
memory_get: { label: "MemoryGet", icon: DatabaseIcon },
|
||||
memory_set: { label: "MemorySet", icon: DatabaseIcon },
|
||||
memory_delete: { label: "MemoryDelete", icon: DatabaseIcon },
|
||||
memory_list: { label: "MemoryList", icon: DatabaseIcon },
|
||||
sessions_spawn: { label: "SpawnSession", icon: GitBranchIcon },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extract a short basename from a file path */
|
||||
function basename(path: string): string {
|
||||
return path.split("/").pop() ?? path
|
||||
}
|
||||
|
||||
/** Smart subtitle based on tool type and args */
|
||||
function getSubtitle(toolName: string, args?: Record<string, unknown>): string {
|
||||
if (!args) return ""
|
||||
switch (toolName) {
|
||||
case "read":
|
||||
case "write":
|
||||
case "edit":
|
||||
return args.path ? basename(String(args.path)) : ""
|
||||
case "exec":
|
||||
case "bash":
|
||||
case "process": {
|
||||
const cmd = String(args.command ?? args.cmd ?? "")
|
||||
return cmd.length > 60 ? cmd.slice(0, 57) + "…" : cmd
|
||||
}
|
||||
case "grep":
|
||||
case "find":
|
||||
return args.pattern ? String(args.pattern) : ""
|
||||
case "glob":
|
||||
return args.pattern ? String(args.pattern) : ""
|
||||
case "web_search":
|
||||
return args.query ? String(args.query) : ""
|
||||
case "web_fetch":
|
||||
try { return new URL(String(args.url)).hostname } catch { return String(args.url ?? "") }
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/** Running-state label per tool */
|
||||
const RUNNING_LABELS: Record<string, string> = {
|
||||
read: "reading…",
|
||||
write: "writing…",
|
||||
edit: "editing…",
|
||||
exec: "running…",
|
||||
bash: "running…",
|
||||
process: "running…",
|
||||
grep: "searching…",
|
||||
find: "searching…",
|
||||
glob: "searching…",
|
||||
web_search: "searching…",
|
||||
web_fetch: "fetching…",
|
||||
}
|
||||
|
||||
/** Stats derived from tool result content */
|
||||
function getStats(toolName: string, toolStatus: string, resultText: string): string {
|
||||
if (toolStatus === "running") return RUNNING_LABELS[toolName] ?? "working…"
|
||||
if (toolStatus === "error" || toolStatus === "interrupted" || !resultText) return ""
|
||||
|
||||
switch (toolName) {
|
||||
case "read": {
|
||||
const lines = resultText.split("\n").length
|
||||
return `${lines} lines`
|
||||
}
|
||||
case "grep": {
|
||||
const matches = resultText.split("\n").filter((l) => l.trim()).length
|
||||
return matches > 0 ? `${matches} matches` : ""
|
||||
}
|
||||
case "glob":
|
||||
case "find": {
|
||||
const files = resultText.split("\n").filter((l) => l.trim()).length
|
||||
return files > 0 ? `${files} files` : ""
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ToolCallItem = memo(function ToolCallItem({ message }: { message: Message }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const { toolName = "", toolStatus = "running", toolArgs, content } = message
|
||||
|
||||
const display = TOOL_DISPLAY[toolName] ?? { label: toolName, icon: CommandLineIcon }
|
||||
const isFinished = toolStatus !== "running"
|
||||
const resultText = getTextContent(content)
|
||||
const hasDetails = isFinished && !!resultText
|
||||
const subtitle = getSubtitle(toolName, toolArgs)
|
||||
const stats = getStats(toolName, toolStatus, resultText)
|
||||
|
||||
return (
|
||||
<div className="py-0.5 px-2.5 text-sm text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${display.label}${subtitle ? ` ${subtitle}` : ""} — ${toolStatus}`}
|
||||
aria-expanded={hasDetails ? expanded : undefined}
|
||||
onClick={() => hasDetails && setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1.5 rounded px-1.5 py-0.5",
|
||||
"text-left transition-[color,background-color]",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 outline-none",
|
||||
hasDetails && "hover:bg-muted/30 cursor-pointer",
|
||||
!hasDetails && "cursor-default",
|
||||
)}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
toolStatus === "running" && "bg-[var(--tool-running)] motion-safe:animate-[glow-pulse_2s_ease-in-out_infinite]",
|
||||
toolStatus === "success" && "bg-[var(--tool-success)]",
|
||||
toolStatus === "error" && "bg-[var(--tool-error)]",
|
||||
toolStatus === "interrupted" && "bg-[var(--tool-error)]",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Tool icon */}
|
||||
<HugeiconsIcon
|
||||
icon={display.icon}
|
||||
strokeWidth={2}
|
||||
className={cn("size-3.5 shrink-0", toolStatus === "error" && "text-[var(--tool-error)]")}
|
||||
/>
|
||||
|
||||
{/* Tool label */}
|
||||
<span className={cn(
|
||||
"font-medium shrink-0",
|
||||
toolStatus === "error" && "text-[var(--tool-error)]",
|
||||
toolStatus === "interrupted" && "text-[var(--tool-error)]",
|
||||
)}>
|
||||
{display.label}
|
||||
</span>
|
||||
|
||||
{/* Smart subtitle */}
|
||||
{subtitle && (
|
||||
<span className="text-muted-foreground/60 truncate min-w-0">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Right-aligned stats */}
|
||||
{stats && (
|
||||
<span className={cn(
|
||||
"ml-auto text-xs text-muted-foreground/60 shrink-0",
|
||||
"font-[tabular-nums]",
|
||||
toolStatus === "running" && "motion-safe:animate-pulse",
|
||||
)}>
|
||||
{stats}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Chevron — visible on hover when expandable */}
|
||||
{hasDetails && (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
strokeWidth={2}
|
||||
className={cn(
|
||||
"size-3 text-muted-foreground/40 shrink-0",
|
||||
"transition-[transform,opacity] duration-150",
|
||||
!stats && "ml-auto",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
expanded && "rotate-90 opacity-100",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded result */}
|
||||
{expanded && resultText && (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={`${display.label} result`}
|
||||
tabIndex={0}
|
||||
className="mt-1 ml-7 text-xs bg-muted rounded p-2 max-h-48 overflow-y-auto whitespace-pre-wrap break-all"
|
||||
>
|
||||
{resultText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
@ -3,6 +3,11 @@
|
|||
import { useRef, useState, useEffect, useCallback } from "react"
|
||||
import type QrScannerLib from "qr-scanner"
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface UseQrScannerOptions {
|
||||
onScan: (data: string) => void
|
||||
onError?: (error: string) => void
|
||||
|
|
@ -14,25 +19,34 @@ export interface UseQrScannerResult {
|
|||
isScanning: boolean
|
||||
error: string | null
|
||||
hasCamera: boolean
|
||||
cornerPoints: Point[] | null
|
||||
hasFlash: boolean
|
||||
toggleFlash: () => Promise<void>
|
||||
start: () => Promise<void>
|
||||
stop: () => void
|
||||
pause: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook wrapping qr-scanner lifecycle.
|
||||
*
|
||||
* - Dynamically imports qr-scanner (keeps it out of SSR bundles)
|
||||
* - Creates/destroys scanner instance based on `enabled`
|
||||
* - Creates/destroys scanner instance based on `enabled` or manual start/stop
|
||||
* - Exposes cornerPoints from scan results, flash control, and lifecycle methods
|
||||
* - Releases camera stream on cleanup
|
||||
*/
|
||||
export function useQrScanner({
|
||||
onScan,
|
||||
onError,
|
||||
enabled = true,
|
||||
enabled = false,
|
||||
}: UseQrScannerOptions): UseQrScannerResult {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const scannerRef = useRef<QrScannerLib | null>(null)
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasCamera, setHasCamera] = useState(true)
|
||||
const [cornerPoints, setCornerPoints] = useState<Point[] | null>(null)
|
||||
const [hasFlash, setHasFlash] = useState(false)
|
||||
|
||||
// Stable callback refs to avoid re-creating scanner on every render
|
||||
const onScanRef = useRef(onScan)
|
||||
|
|
@ -52,67 +66,128 @@ export function useQrScanner({
|
|||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
// Start/stop scanner based on `enabled` and video element
|
||||
const createScanner = useCallback(async () => {
|
||||
if (!videoRef.current || !hasCamera) return
|
||||
|
||||
const mod = await import("qr-scanner")
|
||||
const QrScanner = mod.default
|
||||
|
||||
// Destroy previous instance if any
|
||||
if (scannerRef.current) {
|
||||
scannerRef.current.stop()
|
||||
scannerRef.current.destroy()
|
||||
scannerRef.current = null
|
||||
}
|
||||
|
||||
const scanner = new QrScanner(
|
||||
videoRef.current,
|
||||
(result) => {
|
||||
const points = result.cornerPoints as Point[] | undefined
|
||||
setCornerPoints(points?.length ? points : null)
|
||||
onScanRef.current(result.data)
|
||||
},
|
||||
{
|
||||
preferredCamera: "environment",
|
||||
maxScansPerSecond: 5,
|
||||
returnDetailedScanResult: true,
|
||||
highlightScanRegion: false,
|
||||
highlightCodeOutline: false,
|
||||
onDecodeError: (err) => {
|
||||
// "No QR code found" fires every frame — ignore it
|
||||
if (typeof err === "string" && err.includes("No QR code found")) return
|
||||
console.warn("[QrScanner] decode error:", err)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
scannerRef.current = scanner
|
||||
return scanner
|
||||
}, [hasCamera])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
setError(null)
|
||||
setCornerPoints(null)
|
||||
|
||||
try {
|
||||
let scanner = scannerRef.current
|
||||
if (!scanner) {
|
||||
scanner = (await createScanner()) ?? null
|
||||
if (!scanner) return
|
||||
}
|
||||
|
||||
await scanner.start()
|
||||
setIsScanning(true)
|
||||
|
||||
// Check flash availability after camera starts
|
||||
try {
|
||||
const flash = await scanner.hasFlash()
|
||||
setHasFlash(flash)
|
||||
} catch {
|
||||
setHasFlash(false)
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message || "Camera access failed"
|
||||
setError(msg)
|
||||
setIsScanning(false)
|
||||
onErrorRef.current?.(msg)
|
||||
}
|
||||
}, [createScanner])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (scannerRef.current) {
|
||||
scannerRef.current.stop()
|
||||
scannerRef.current.destroy()
|
||||
scannerRef.current = null
|
||||
}
|
||||
setIsScanning(false)
|
||||
setCornerPoints(null)
|
||||
setHasFlash(false)
|
||||
}, [])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
scannerRef.current?.pause()
|
||||
setIsScanning(false)
|
||||
}, [])
|
||||
|
||||
const toggleFlash = useCallback(async () => {
|
||||
if (!scannerRef.current) return
|
||||
try {
|
||||
await scannerRef.current.toggleFlash()
|
||||
} catch {
|
||||
// Flash not supported or other error — silently ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-start/stop based on `enabled` prop (backwards compatible)
|
||||
useEffect(() => {
|
||||
if (!enabled || !videoRef.current || !hasCamera) return
|
||||
|
||||
let destroyed = false
|
||||
const video = videoRef.current
|
||||
|
||||
import("qr-scanner").then((mod) => {
|
||||
if (destroyed) return
|
||||
const QrScanner = mod.default
|
||||
|
||||
const scanner = new QrScanner(
|
||||
video,
|
||||
(result) => {
|
||||
console.log("[QrScanner] scanned:", result.data)
|
||||
onScanRef.current(result.data)
|
||||
},
|
||||
{
|
||||
preferredCamera: "environment",
|
||||
maxScansPerSecond: 5,
|
||||
returnDetailedScanResult: true,
|
||||
highlightScanRegion: false,
|
||||
highlightCodeOutline: false,
|
||||
onDecodeError: (err) => {
|
||||
// "No QR code found" fires every frame — ignore it
|
||||
if (typeof err === "string" && err.includes("No QR code found")) return
|
||||
console.warn("[QrScanner] decode error:", err)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
scannerRef.current = scanner
|
||||
|
||||
scanner
|
||||
.start()
|
||||
.then(() => {
|
||||
if (!destroyed) {
|
||||
console.log("[QrScanner] started successfully")
|
||||
setIsScanning(true)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (destroyed) return
|
||||
const msg = err.message || "Camera access failed"
|
||||
setError(msg)
|
||||
setIsScanning(false)
|
||||
onErrorRef.current?.(msg)
|
||||
})
|
||||
})
|
||||
if (enabled) {
|
||||
start()
|
||||
} else if (!enabled && scannerRef.current) {
|
||||
stop()
|
||||
}
|
||||
}, [enabled, start, stop])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
destroyed = true
|
||||
if (scannerRef.current) {
|
||||
scannerRef.current.stop()
|
||||
scannerRef.current.destroy()
|
||||
scannerRef.current = null
|
||||
}
|
||||
setIsScanning(false)
|
||||
}
|
||||
}, [enabled, hasCamera])
|
||||
}, [])
|
||||
|
||||
return { videoRef, isScanning, error, hasCamera }
|
||||
return {
|
||||
videoRef,
|
||||
isScanning,
|
||||
error,
|
||||
hasCamera,
|
||||
cornerPoints,
|
||||
hasFlash,
|
||||
toggleFlash,
|
||||
start,
|
||||
stop,
|
||||
pause,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { ContentBlock } from "@multica/sdk"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/** Extract concatenated plain text from a ContentBlock array */
|
||||
export function getTextContent(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@
|
|||
--scrollbar-thumb: oklch(0.82 0.003 286);
|
||||
--scrollbar-thumb-hover: oklch(0.705 0.015 286.067);
|
||||
--scrollbar-track: transparent;
|
||||
--tool-running: oklch(0.6 0.18 250);
|
||||
--tool-success: oklch(0.72 0.12 145);
|
||||
--tool-error: oklch(0.65 0.2 25);
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -123,6 +126,9 @@
|
|||
--scrollbar-thumb: oklch(1 0 0 / 15%);
|
||||
--scrollbar-thumb-hover: oklch(1 0 0 / 30%);
|
||||
--scrollbar-track: transparent;
|
||||
--tool-running: oklch(0.65 0.2 250);
|
||||
--tool-success: oklch(0.65 0.15 145);
|
||||
--tool-error: oklch(0.7 0.2 22);
|
||||
}
|
||||
|
||||
/* Shiki dual themes: CSS-only light/dark switching via CSS variables */
|
||||
|
|
@ -213,3 +219,9 @@
|
|||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tool status: running glow pulse */
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 var(--tool-running); }
|
||||
50% { box-shadow: 0 0 0 3px oklch(0.6 0.2 250 / 0); }
|
||||
}
|
||||
|
|
|
|||
678
pnpm-lock.yaml
generated
678
pnpm-lock.yaml
generated
|
|
@ -427,6 +427,12 @@ importers:
|
|||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
devDependencies:
|
||||
'@mariozechner/pi-agent-core':
|
||||
specifier: ^0.50.3
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.50.3
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@types/uuid':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0
|
||||
|
|
@ -470,6 +476,18 @@ importers:
|
|||
'@multica/store':
|
||||
specifier: workspace:*
|
||||
version: link:../store
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^3.19.0
|
||||
version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.19.0
|
||||
version: 3.19.0
|
||||
'@tiptap/react':
|
||||
specifier: ^3.19.0
|
||||
version: 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.19.0
|
||||
version: 3.19.0
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -2714,6 +2732,9 @@ packages:
|
|||
'@react-navigation/routers@7.5.3':
|
||||
resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==}
|
||||
|
||||
'@remirror/core-constants@3.0.0':
|
||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||
|
||||
'@rn-primitives/portal@1.3.0':
|
||||
resolution: {integrity: sha512-a2DSce7TcSfcs0cCngLadAJOvx/+mdH9NRu+GxkX8NPRsGGhJvDEOqouMgDqLwx7z9mjXoUaZcwaVcemUSW9/A==}
|
||||
peerDependencies:
|
||||
|
|
@ -3214,6 +3235,160 @@ packages:
|
|||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tiptap/core@3.19.0':
|
||||
resolution: {integrity: sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': ^3.19.0
|
||||
|
||||
'@tiptap/extension-blockquote@3.19.0':
|
||||
resolution: {integrity: sha512-y3UfqY9KD5XwWz3ndiiJ089Ij2QKeiXy/g1/tlAN/F1AaWsnkHEHMLxCP1BIqmMpwsX7rZjMLN7G5Lp7c9682A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extension-bold@3.19.0':
|
||||
resolution: {integrity: sha512-UZgb1d0XK4J/JRIZ7jW+s4S6KjuEDT2z1PPM6ugcgofgJkWQvRZelCPbmtSFd3kwsD+zr9UPVgTh9YIuGQ8t+Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.19.0':
|
||||
resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
'@tiptap/pm': ^3.19.0
|
||||
|
||||
'@tiptap/extension-bullet-list@3.19.0':
|
||||
resolution: {integrity: sha512-F9uNnqd0xkJbMmRxVI5RuVxwB9JaCH/xtRqOUNQZnRBt7IdAElCY+Dvb4hMCtiNv+enGM/RFGJuFHR9TxmI7rw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.19.0
|
||||
|
||||
'@tiptap/extension-code-block@3.19.0':
|
||||
resolution: {integrity: sha512-b/2qR+tMn8MQb+eaFYgVk4qXnLNkkRYmwELQ8LEtEDQPxa5Vl7J3eu8+4OyoIFhZrNDZvvoEp80kHMCP8sI6rg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
'@tiptap/pm': ^3.19.0
|
||||
|
||||
'@tiptap/extension-code@3.19.0':
|
||||
resolution: {integrity: sha512-2kqqQIXBXj2Or+4qeY3WoE7msK+XaHKL6EKOcKlOP2BW8eYqNTPzNSL+PfBDQ3snA7ljZQkTs/j4GYDj90vR1A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extension-document@3.19.0':
|
||||
resolution: {integrity: sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extension-dropcursor@3.19.0':
|
||||
resolution: {integrity: sha512-sf3dEZXiLvsGqVK2maUIzXY6qtYYCvBumag7+VPTMGQ0D4hiZ1X/4ukt4+6VXDg5R2WP1CoIt/QvUetUjWNhbQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.19.0
|
||||
|
||||
'@tiptap/extension-floating-menu@3.19.0':
|
||||
resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==}
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.0.0
|
||||
'@tiptap/core': ^3.19.0
|
||||
'@tiptap/pm': ^3.19.0
|
||||
|
||||
'@tiptap/extension-gapcursor@3.19.0':
|
||||
resolution: {integrity: sha512-w7DACS4oSZaDWjz7gropZHPc9oXqC9yERZTcjWxyORuuIh1JFf0TRYspleK+OK28plK/IftojD/yUDn1MTRhvA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.19.0
|
||||
|
||||
'@tiptap/extension-hard-break@3.19.0':
|
||||
resolution: {integrity: sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extension-heading@3.19.0':
|
||||
resolution: {integrity: sha512-uLpLlfyp086WYNOc0ekm1gIZNlEDfmzOhKzB0Hbyi6jDagTS+p9mxUNYeYOn9jPUxpFov43+Wm/4E24oY6B+TQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.19.0':
|
||||
resolution: {integrity: sha512-iqUHmgMGhMgYGwG6L/4JdelVQ5Mstb4qHcgTGd/4dkcUOepILvhdxajPle7OEdf9sRgjQO6uoAU5BVZVC26+ng==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
'@tiptap/pm': ^3.19.0
|
||||
|
||||
'@tiptap/extension-italic@3.19.0':
|
||||
resolution: {integrity: sha512-6GffxOnS/tWyCbDkirWNZITiXRta9wrCmrfa4rh+v32wfaOL1RRQNyqo9qN6Wjyl1R42Js+yXTzTTzZsOaLMYA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extension-link@3.19.0':
|
||||
resolution: {integrity: sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
'@tiptap/pm': ^3.19.0
|
||||
|
||||
'@tiptap/extension-list-item@3.19.0':
|
||||
resolution: {integrity: sha512-VsSKuJz4/Tb6ZmFkXqWpDYkRzmaLTyE6dNSEpNmUpmZ32sMqo58mt11/huADNwfBFB0Ve7siH/VnFNIJYY3xvg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.19.0
|
||||
|
||||
'@tiptap/extension-list-keymap@3.19.0':
|
||||
resolution: {integrity: sha512-bxgmAgA3RzBGA0GyTwS2CC1c+QjkJJq9hC+S6PSOWELGRiTbwDN3MANksFXLjntkTa0N5fOnL27vBHtMStURqw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.19.0
|
||||
|
||||
'@tiptap/extension-list@3.19.0':
|
||||
resolution: {integrity: sha512-N6nKbFB2VwMsPlCw67RlAtYSK48TAsAUgjnD+vd3ieSlIufdQnLXDFUP6hFKx9mwoUVUgZGz02RA6bkxOdYyTw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
'@tiptap/pm': ^3.19.0
|
||||
|
||||
'@tiptap/extension-ordered-list@3.19.0':
|
||||
resolution: {integrity: sha512-cxGsINquwHYE1kmhAcLNLHAofmoDEG6jbesR5ybl7tU5JwtKVO7S/xZatll2DU1dsDAXWPWEeeMl4e/9svYjCg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.19.0
|
||||
|
||||
'@tiptap/extension-paragraph@3.19.0':
|
||||
resolution: {integrity: sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extension-placeholder@3.19.0':
|
||||
resolution: {integrity: sha512-i15OfgyI4IDCYAcYSKUMnuZkYuUInfanjf9zquH8J2BETiomf/jZldVCp/QycMJ8DOXZ38fXDc99wOygnSNySg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.19.0
|
||||
|
||||
'@tiptap/extension-strike@3.19.0':
|
||||
resolution: {integrity: sha512-xYpabHsv7PccLUBQaP8AYiFCnYbx6P93RHPd0lgNwhdOjYFd931Zy38RyoxPHAgbYVmhf1iyx7lpuLtBnhS5dA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extension-text@3.19.0':
|
||||
resolution: {integrity: sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extension-underline@3.19.0':
|
||||
resolution: {integrity: sha512-800MGEWfG49j10wQzAFiW/ele1HT04MamcL8iyuPNu7ZbjbGN2yknvdrJlRy7hZlzIrVkZMr/1tz62KN33VHIw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
|
||||
'@tiptap/extensions@3.19.0':
|
||||
resolution: {integrity: sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
'@tiptap/pm': ^3.19.0
|
||||
|
||||
'@tiptap/pm@3.19.0':
|
||||
resolution: {integrity: sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==}
|
||||
|
||||
'@tiptap/react@3.19.0':
|
||||
resolution: {integrity: sha512-GQQMUUXMpNd8tRjc1jDK3tDRXFugJO7C928EqmeBcBzTKDrFIJ3QUoZKEPxUNb6HWhZ2WL7q00fiMzsv4DNSmg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.19.0
|
||||
'@tiptap/pm': ^3.19.0
|
||||
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tiptap/starter-kit@3.19.0':
|
||||
resolution: {integrity: sha512-dTCkHEz+Y8ADxX7h+xvl6caAj+3nII/wMB1rTQchSuNKqJTOrzyUsCWm094+IoZmLT738wANE0fRIgziNHs/ug==}
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -3303,9 +3478,15 @@ packages:
|
|||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/mdurl@2.0.0':
|
||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||
|
||||
'@types/mime-types@2.1.4':
|
||||
resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==}
|
||||
|
||||
|
|
@ -3350,6 +3531,9 @@ packages:
|
|||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/uuid@11.0.0':
|
||||
resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==}
|
||||
deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.
|
||||
|
|
@ -4382,6 +4566,9 @@ packages:
|
|||
crc@3.8.0:
|
||||
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cross-fetch@3.2.0:
|
||||
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
|
||||
|
||||
|
|
@ -5198,6 +5385,10 @@ packages:
|
|||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-equals@5.4.0:
|
||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-glob@3.3.1:
|
||||
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
|
@ -6344,6 +6535,9 @@ packages:
|
|||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
linkifyjs@4.3.2:
|
||||
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
|
||||
|
||||
load-esm@1.0.3:
|
||||
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
||||
engines: {node: '>=13.2.0'}
|
||||
|
|
@ -6440,6 +6634,10 @@ packages:
|
|||
makeerror@1.0.12:
|
||||
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
|
||||
|
||||
markdown-it@14.1.0:
|
||||
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
||||
hasBin: true
|
||||
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
|
|
@ -6507,6 +6705,9 @@ packages:
|
|||
mdn-data@2.0.14:
|
||||
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
|
||||
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -7049,6 +7250,9 @@ packages:
|
|||
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
|
||||
outvariant@1.4.3:
|
||||
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
|
||||
|
||||
|
|
@ -7344,6 +7548,64 @@ packages:
|
|||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
prosemirror-changeset@2.3.1:
|
||||
resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==}
|
||||
|
||||
prosemirror-collab@1.3.1:
|
||||
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
|
||||
|
||||
prosemirror-gapcursor@1.4.0:
|
||||
resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==}
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||
|
||||
prosemirror-markdown@1.13.4:
|
||||
resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
|
||||
|
||||
prosemirror-menu@1.2.5:
|
||||
resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==}
|
||||
|
||||
prosemirror-model@1.25.4:
|
||||
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
|
||||
|
||||
prosemirror-schema-basic@1.2.4:
|
||||
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||
|
||||
prosemirror-trailing-node@3.0.0:
|
||||
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
|
||||
peerDependencies:
|
||||
prosemirror-model: ^1.22.1
|
||||
prosemirror-state: ^1.4.2
|
||||
prosemirror-view: ^1.33.8
|
||||
|
||||
prosemirror-transform@1.11.0:
|
||||
resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==}
|
||||
|
||||
prosemirror-view@1.41.5:
|
||||
resolution: {integrity: sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
|
@ -7358,6 +7620,10 @@ packages:
|
|||
pump@3.0.3:
|
||||
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -7762,6 +8028,9 @@ packages:
|
|||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
|
||||
router@2.2.0:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
|
@ -8826,6 +9095,9 @@ packages:
|
|||
vlq@1.0.1:
|
||||
resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==}
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
walker@1.0.8:
|
||||
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
|
||||
|
||||
|
|
@ -9094,6 +9366,12 @@ snapshots:
|
|||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@anthropic-ai/sdk@0.71.2(zod@4.3.6)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
optionalDependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
|
|
@ -10876,6 +11154,17 @@ snapshots:
|
|||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))':
|
||||
dependencies:
|
||||
google-auth-library: 10.5.0
|
||||
ws: 8.18.3
|
||||
optionalDependencies:
|
||||
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@hono/node-server@1.19.9(hono@4.11.7)':
|
||||
dependencies:
|
||||
hono: 4.11.7
|
||||
|
|
@ -11218,6 +11507,19 @@ snapshots:
|
|||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.50.3
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@3.25.76)
|
||||
|
|
@ -11242,6 +11544,30 @@ snapshots:
|
|||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.978.0
|
||||
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))
|
||||
'@mistralai/mistralai': 1.10.0
|
||||
'@sinclair/typebox': 0.34.48
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
chalk: 5.6.2
|
||||
openai: 6.10.0(ws@8.18.3)(zod@4.3.6)
|
||||
partial-json: 0.1.7
|
||||
proxy-agent: 6.5.0
|
||||
undici: 7.19.2
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@mariozechner/clipboard': 0.3.0
|
||||
|
|
@ -11305,6 +11631,29 @@ snapshots:
|
|||
- hono
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.9(hono@4.11.7)
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.6
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 7.5.1(express@5.2.1)
|
||||
jose: 6.1.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
zod: 4.3.6
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- hono
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@mozilla/readability@0.6.0': {}
|
||||
|
||||
'@mswjs/interceptors@0.40.0':
|
||||
|
|
@ -11849,6 +12198,8 @@ snapshots:
|
|||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
|
||||
'@remirror/core-constants@3.0.0': {}
|
||||
|
||||
'@rn-primitives/portal@1.3.0(@types/react@19.1.17)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0))':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
|
@ -12392,6 +12743,187 @@ snapshots:
|
|||
tailwindcss: 4.1.18
|
||||
vite: 5.4.21(@types/node@25.0.10)(lightningcss@1.30.2)(terser@5.46.0)
|
||||
|
||||
'@tiptap/core@3.19.0(@tiptap/pm@3.19.0)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.19.0
|
||||
|
||||
'@tiptap/extension-blockquote@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-bold@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/pm': 3.19.0
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-bullet-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/pm': 3.19.0
|
||||
|
||||
'@tiptap/extension-code@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-document@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/pm': 3.19.0
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-gapcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-heading@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/pm': 3.19.0
|
||||
|
||||
'@tiptap/extension-italic@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-link@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/pm': 3.19.0
|
||||
linkifyjs: 4.3.2
|
||||
|
||||
'@tiptap/extension-list-item@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-list-keymap@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/pm': 3.19.0
|
||||
|
||||
'@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-placeholder@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-strike@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-text@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extension-underline@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
|
||||
'@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/pm': 3.19.0
|
||||
|
||||
'@tiptap/pm@3.19.0':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.3.1
|
||||
prosemirror-collab: 1.3.1
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-dropcursor: 1.8.2
|
||||
prosemirror-gapcursor: 1.4.0
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-inputrules: 1.5.1
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-markdown: 1.13.4
|
||||
prosemirror-menu: 1.2.5
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-schema-basic: 1.2.4
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.5
|
||||
|
||||
'@tiptap/react@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/pm': 3.19.0
|
||||
'@types/react': 19.1.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.1.17)
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
fast-equals: 5.4.0
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@tiptap/extension-bubble-menu': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
'@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
transitivePeerDependencies:
|
||||
- '@floating-ui/dom'
|
||||
|
||||
'@tiptap/starter-kit@3.19.0':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/extension-blockquote': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-bold': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-bullet-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-code-block': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
'@tiptap/extension-document': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-dropcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-gapcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-hard-break': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-heading': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
'@tiptap/extension-italic': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-link': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
'@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
'@tiptap/extension-list-item': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-list-keymap': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-ordered-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-strike': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-text': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extension-underline': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
|
||||
'@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
'@tiptap/pm': 3.19.0
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
|
@ -12501,10 +13033,17 @@ snapshots:
|
|||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
'@types/mdurl': 2.0.0
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/mdurl@2.0.0': {}
|
||||
|
||||
'@types/mime-types@2.1.4': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
|
@ -12547,6 +13086,8 @@ snapshots:
|
|||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/uuid@11.0.0':
|
||||
dependencies:
|
||||
uuid: 13.0.0
|
||||
|
|
@ -13762,6 +14303,8 @@ snapshots:
|
|||
buffer: 5.7.1
|
||||
optional: true
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-fetch@3.2.0:
|
||||
dependencies:
|
||||
node-fetch: 2.7.0
|
||||
|
|
@ -14905,6 +15448,8 @@ snapshots:
|
|||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-equals@5.4.0: {}
|
||||
|
||||
fast-glob@3.3.1:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
|
|
@ -16147,6 +16692,8 @@ snapshots:
|
|||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
linkifyjs@4.3.2: {}
|
||||
|
||||
load-esm@1.0.3: {}
|
||||
|
||||
locate-path@5.0.0:
|
||||
|
|
@ -16230,6 +16777,15 @@ snapshots:
|
|||
dependencies:
|
||||
tmpl: 1.0.5
|
||||
|
||||
markdown-it@14.1.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.0
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
marked@15.0.12: {}
|
||||
|
|
@ -16398,6 +16954,8 @@ snapshots:
|
|||
|
||||
mdn-data@2.0.14: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
|
@ -17124,6 +17682,11 @@ snapshots:
|
|||
ws: 8.18.3
|
||||
zod: 3.25.76
|
||||
|
||||
openai@6.10.0(ws@8.18.3)(zod@4.3.6):
|
||||
optionalDependencies:
|
||||
ws: 8.18.3
|
||||
zod: 4.3.6
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
|
@ -17154,6 +17717,8 @@ snapshots:
|
|||
string-width: 7.2.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
outvariant@1.4.3: {}
|
||||
|
||||
own-keys@1.0.1:
|
||||
|
|
@ -17456,6 +18021,109 @@ snapshots:
|
|||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
prosemirror-changeset@2.3.1:
|
||||
dependencies:
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-collab@1.3.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.5
|
||||
|
||||
prosemirror-gapcursor@1.4.0:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.5
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.5
|
||||
rope-sequence: 1.3.4
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
prosemirror-markdown@1.13.4:
|
||||
dependencies:
|
||||
'@types/markdown-it': 14.1.2
|
||||
markdown-it: 14.1.0
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-menu@1.2.5:
|
||||
dependencies:
|
||||
crelt: 1.0.6
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-state: 1.4.4
|
||||
|
||||
prosemirror-model@1.25.4:
|
||||
dependencies:
|
||||
orderedmap: 2.1.1
|
||||
|
||||
prosemirror-schema-basic@1.2.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.5
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.5
|
||||
|
||||
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5):
|
||||
dependencies:
|
||||
'@remirror/core-constants': 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.5
|
||||
|
||||
prosemirror-transform@1.11.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-view@1.41.5:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
|
|
@ -17481,6 +18149,8 @@ snapshots:
|
|||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qr-scanner@1.4.2:
|
||||
|
|
@ -18024,6 +18694,8 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc': 4.57.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
|
@ -19214,6 +19886,8 @@ snapshots:
|
|||
|
||||
vlq@1.0.1: {}
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
walker@1.0.8:
|
||||
dependencies:
|
||||
makeerror: 1.0.12
|
||||
|
|
@ -19414,6 +20088,10 @@ snapshots:
|
|||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
|
|
|||
|
|
@ -221,4 +221,19 @@ export class AsyncAgent {
|
|||
getMessages(): AgentMessage[] {
|
||||
return this.agent.getMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current provider and model information.
|
||||
*/
|
||||
getProviderInfo(): { provider: string; model: string | undefined } {
|
||||
return this.agent.getProviderInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different provider and/or model.
|
||||
* This updates the agent's model without recreating the session.
|
||||
*/
|
||||
setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } {
|
||||
return this.agent.setProvider(providerId, modelId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,44 +2,46 @@
|
|||
* Dev command - Start development servers
|
||||
*
|
||||
* Usage:
|
||||
* multica dev Start all services (gateway + console + web)
|
||||
* multica dev gateway Start gateway only (:3000)
|
||||
* multica dev console Start console only (:4000)
|
||||
* multica dev Start desktop app (with embedded Hub)
|
||||
* multica dev gateway Start gateway only (:3000) - for remote clients
|
||||
* multica dev web Start web app only (:3001)
|
||||
* multica dev desktop Start desktop app
|
||||
* multica dev all Start all services (gateway + web)
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { cyan, yellow, green, dim, red } from "../colors.js";
|
||||
|
||||
type Service = "all" | "gateway" | "console" | "web" | "desktop" | "help";
|
||||
type Service = "all" | "gateway" | "web" | "desktop" | "help";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
${cyan("Usage:")} multica dev [service]
|
||||
|
||||
${cyan("Services:")}
|
||||
${yellow("(default)")} Start all services (gateway + console + web)
|
||||
${yellow("gateway")} Start Gateway server (:3000)
|
||||
${yellow("console")} Start Console server (:4000)
|
||||
${yellow("(default)")} Start Desktop app (with embedded Hub)
|
||||
${yellow("gateway")} Start Gateway server (:3000) - for remote clients
|
||||
${yellow("web")} Start Web app (:3001)
|
||||
${yellow("desktop")} Start Desktop app
|
||||
${yellow("all")} Start all services (gateway + web)
|
||||
${yellow("help")} Show this help
|
||||
|
||||
${cyan("Architecture:")}
|
||||
Frontend (web:3001 / desktop)
|
||||
Desktop App (standalone)
|
||||
└─ Embedded Hub + Agent Engine
|
||||
└─ (Optional) Gateway connection for remote access
|
||||
|
||||
Web App (requires Gateway)
|
||||
→ Gateway (WebSocket, :3000)
|
||||
→ Console Hub (multi-agent coordination, :4000)
|
||||
→ Agent Engine
|
||||
→ Hub + Agent Engine
|
||||
|
||||
${cyan("Examples:")}
|
||||
${dim("# Start all services")}
|
||||
${dim("# Start desktop app (recommended for local development)")}
|
||||
multica dev
|
||||
|
||||
${dim("# Start only the gateway")}
|
||||
${dim("# Start desktop with remote Gateway for mobile access")}
|
||||
GATEWAY_URL=http://localhost:3000 multica dev &
|
||||
multica dev gateway
|
||||
|
||||
${dim("# Start web and gateway separately")}
|
||||
${dim("# Start web app with gateway")}
|
||||
multica dev gateway &
|
||||
multica dev web
|
||||
`);
|
||||
|
|
@ -52,7 +54,7 @@ interface DevOptions {
|
|||
|
||||
function parseArgs(argv: string[]): DevOptions {
|
||||
const args = [...argv];
|
||||
let service: Service = "all";
|
||||
let service: Service = "desktop";
|
||||
let watch = true;
|
||||
|
||||
while (args.length > 0) {
|
||||
|
|
@ -68,7 +70,7 @@ function parseArgs(argv: string[]): DevOptions {
|
|||
}
|
||||
|
||||
// Service name
|
||||
if (["gateway", "console", "web", "desktop", "all", "help"].includes(arg)) {
|
||||
if (["gateway", "web", "desktop", "all", "help"].includes(arg)) {
|
||||
service = arg as Service;
|
||||
}
|
||||
}
|
||||
|
|
@ -105,14 +107,6 @@ async function startGateway(watch: boolean) {
|
|||
});
|
||||
}
|
||||
|
||||
async function startConsole(watch: boolean) {
|
||||
const watchFlag = watch ? "--watch" : "";
|
||||
return runCommand("tsx", [watchFlag, "src/console/main.ts"].filter(Boolean), {
|
||||
name: "console",
|
||||
color: "\x1b[33m", // yellow
|
||||
});
|
||||
}
|
||||
|
||||
async function startWeb() {
|
||||
return runCommand("pnpm", ["--filter", "@multica/web", "dev"], {
|
||||
name: "web",
|
||||
|
|
@ -130,20 +124,17 @@ async function startDesktop() {
|
|||
async function startAll(watch: boolean) {
|
||||
console.log(`\n${cyan("Starting all services...")}\n`);
|
||||
console.log(` ${"\x1b[34m"}Gateway${"\x1b[0m"} → http://localhost:3000`);
|
||||
console.log(` ${"\x1b[33m"}Console${"\x1b[0m"} → http://localhost:4000`);
|
||||
console.log(` ${"\x1b[32m"}Web${"\x1b[0m"} → http://localhost:3001`);
|
||||
console.log("");
|
||||
|
||||
// Start all services
|
||||
const gateway = await startGateway(watch);
|
||||
const console_ = await startConsole(watch);
|
||||
const web = await startWeb();
|
||||
|
||||
// Handle Ctrl+C
|
||||
const cleanup = () => {
|
||||
console.log(`\n${dim("Stopping all services...")}`);
|
||||
gateway.kill();
|
||||
console_.kill();
|
||||
web.kill();
|
||||
process.exit(0);
|
||||
};
|
||||
|
|
@ -154,7 +145,6 @@ async function startAll(watch: boolean) {
|
|||
// Wait for all to exit
|
||||
await Promise.all([
|
||||
new Promise((resolve) => gateway.on("exit", resolve)),
|
||||
new Promise((resolve) => console_.on("exit", resolve)),
|
||||
new Promise((resolve) => web.on("exit", resolve)),
|
||||
]);
|
||||
}
|
||||
|
|
@ -168,11 +158,6 @@ export async function devCommand(args: string[]): Promise<void> {
|
|||
await startGateway(opts.watch);
|
||||
break;
|
||||
|
||||
case "console":
|
||||
console.log(`\n${cyan("Starting Console...")} → http://localhost:4000\n`);
|
||||
await startConsole(opts.watch);
|
||||
break;
|
||||
|
||||
case "web":
|
||||
console.log(`\n${cyan("Starting Web App...")} → http://localhost:3001\n`);
|
||||
await startWeb();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import JSON5 from "json5";
|
||||
import { DATA_DIR } from "../shared/paths.js";
|
||||
|
||||
type ProviderConfig = {
|
||||
// API Key authentication
|
||||
apiKey?: string | undefined;
|
||||
// OAuth authentication
|
||||
oauthToken?: string | undefined;
|
||||
oauthRefreshToken?: string | undefined;
|
||||
oauthExpiresAt?: number | undefined;
|
||||
// Common
|
||||
baseUrl?: string | undefined;
|
||||
model?: string | undefined;
|
||||
};
|
||||
|
|
@ -223,6 +229,132 @@ export class CredentialManager {
|
|||
this.skillsConfig = null;
|
||||
this.resolvedSkillsEnv = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the API key for a provider and save to credentials.json5.
|
||||
* Creates the file if it doesn't exist.
|
||||
*/
|
||||
setLlmProviderApiKey(provider: string, apiKey: string): void {
|
||||
const path = getCredentialsPath();
|
||||
|
||||
// Load existing config or create new one
|
||||
let config: CredentialsConfig = { version: 1 };
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
config = JSON5.parse(raw) as CredentialsConfig;
|
||||
} catch {
|
||||
// If parse fails, start fresh
|
||||
config = { version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure structure exists
|
||||
if (!config.llm) {
|
||||
config.llm = {};
|
||||
}
|
||||
if (!config.llm.providers) {
|
||||
config.llm.providers = {};
|
||||
}
|
||||
|
||||
// Set or update the provider config
|
||||
const existing = config.llm.providers[provider] ?? {};
|
||||
config.llm.providers[provider] = {
|
||||
...existing,
|
||||
apiKey,
|
||||
};
|
||||
|
||||
// Write back to file
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
writeFileSync(path, content, "utf8");
|
||||
|
||||
// Reset cache so next read picks up the change
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set OAuth token for a provider and save to credentials.json5.
|
||||
* Used for OAuth providers like claude-code and openai-codex.
|
||||
*/
|
||||
setLlmProviderOAuthToken(
|
||||
provider: string,
|
||||
token: string,
|
||||
refreshToken?: string,
|
||||
expiresAt?: number,
|
||||
): void {
|
||||
const path = getCredentialsPath();
|
||||
|
||||
// Load existing config or create new one
|
||||
let config: CredentialsConfig = { version: 1 };
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
config = JSON5.parse(raw) as CredentialsConfig;
|
||||
} catch {
|
||||
config = { version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure structure exists
|
||||
if (!config.llm) {
|
||||
config.llm = {};
|
||||
}
|
||||
if (!config.llm.providers) {
|
||||
config.llm.providers = {};
|
||||
}
|
||||
|
||||
// Set or update the provider config
|
||||
const existing = config.llm.providers[provider] ?? {};
|
||||
config.llm.providers[provider] = {
|
||||
...existing,
|
||||
oauthToken: token,
|
||||
oauthRefreshToken: refreshToken,
|
||||
oauthExpiresAt: expiresAt,
|
||||
};
|
||||
|
||||
// Write back to file
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
writeFileSync(path, content, "utf8");
|
||||
|
||||
// Reset cache
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default LLM provider and save to credentials.json5.
|
||||
*/
|
||||
setDefaultLlmProvider(provider: string): void {
|
||||
const path = getCredentialsPath();
|
||||
|
||||
// Load existing config or create new one
|
||||
let config: CredentialsConfig = { version: 1 };
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
config = JSON5.parse(raw) as CredentialsConfig;
|
||||
} catch {
|
||||
config = { version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure structure exists
|
||||
if (!config.llm) {
|
||||
config.llm = {};
|
||||
}
|
||||
|
||||
// Set default provider
|
||||
config.llm.provider = provider;
|
||||
|
||||
// Write back to file
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
writeFileSync(path, content, "utf8");
|
||||
|
||||
// Reset cache
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
export const credentialManager = new CredentialManager();
|
||||
|
|
|
|||
|
|
@ -92,14 +92,26 @@ You wake up fresh each session. These files are your continuity:
|
|||
|
||||
Capture what matters. Decisions, context, things to remember.
|
||||
|
||||
### Write It Down
|
||||
### 📝 Write It Down - No "Mental Notes"!
|
||||
|
||||
- Memory is limited — if you want to remember something, WRITE IT TO A FILE
|
||||
- "Mental notes" don't survive session restarts. Files do.
|
||||
- When you learn something about the user → update \`USER.md\`
|
||||
- When you learn a lesson → update \`MEMORY.md\`
|
||||
⚠️ **CRITICAL**: You CANNOT "remember" things mentally. Your memory resets each session. If you don't write it to a file, it's gone.
|
||||
|
||||
**Which file to edit:**
|
||||
- \`user.md\` — About your human: name, preferences, habits, context, anything personal
|
||||
- \`memory.md\` — Your learnings: decisions made, lessons learned, important context
|
||||
- \`workspace.md\` — Your rules: conventions, workflows, how you should operate
|
||||
- \`soul.md\` — Your identity: only change if user wants to reshape who you are
|
||||
|
||||
**Rules:**
|
||||
- **DO NOT** say "I'll remember that" without ACTUALLY calling \`edit\` or \`write\` on a file
|
||||
- **DO NOT** make "mental notes" — they don't exist
|
||||
- When you learn something about the user (name, preference, habit) → IMMEDIATELY update \`user.md\`
|
||||
- When you learn a lesson, make a decision, or gain context → IMMEDIATELY update \`memory.md\`
|
||||
- When you discover a better workflow or convention → update \`workspace.md\`
|
||||
- When you make a mistake → document it so future-you doesn't repeat it
|
||||
|
||||
**Text > Brain** 📝
|
||||
|
||||
## Safety
|
||||
|
||||
- Don't exfiltrate private data. Ever.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
resolveApiKeyForProvider,
|
||||
resolveBaseUrl,
|
||||
resolveModelId,
|
||||
PROVIDER_ALIAS,
|
||||
getDefaultModel,
|
||||
} from "./providers/index.js";
|
||||
import { SessionManager } from "./session/session-manager.js";
|
||||
import { ProfileManager } from "./profile/index.js";
|
||||
|
|
@ -82,7 +84,7 @@ export class Agent {
|
|||
private initialized = false;
|
||||
|
||||
// Auth profile rotation state
|
||||
private readonly resolvedProvider: string;
|
||||
private resolvedProvider: string;
|
||||
private currentApiKey: string | undefined;
|
||||
private currentProfileId: string | undefined;
|
||||
private profileCandidates: string[];
|
||||
|
|
@ -598,6 +600,72 @@ export class Agent {
|
|||
this.profile?.updateStyle(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current provider and model information.
|
||||
*/
|
||||
getProviderInfo(): { provider: string; model: string | undefined } {
|
||||
return {
|
||||
provider: this.resolvedProvider,
|
||||
model: this.agent.state.model?.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different provider and/or model.
|
||||
* This updates the agent's model without recreating the session.
|
||||
*/
|
||||
setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } {
|
||||
// Resolve the actual provider (handle aliases like claude-code -> anthropic)
|
||||
const actualProvider = PROVIDER_ALIAS[providerId] ?? providerId;
|
||||
|
||||
// Resolve the model
|
||||
const targetModel = modelId ?? getDefaultModel(providerId) ?? getDefaultModel(actualProvider);
|
||||
const model = resolveModel({ provider: providerId, model: targetModel });
|
||||
|
||||
if (!model) {
|
||||
throw new Error(`Failed to resolve model for provider: ${providerId}, model: ${targetModel}`);
|
||||
}
|
||||
|
||||
// Resolve API key for the new provider
|
||||
// For OAuth providers (claude-code, openai-codex), we need to use the original providerId
|
||||
// because OAuth credentials are resolved by the original provider name, not the alias
|
||||
const resolved = resolveApiKeyForProvider(providerId);
|
||||
if (resolved) {
|
||||
this.currentApiKey = resolved.apiKey;
|
||||
this.currentProfileId = resolved.profileId;
|
||||
} else {
|
||||
// Fallback: try with actual provider (for API key based providers)
|
||||
this.currentApiKey = resolveApiKey(actualProvider);
|
||||
this.currentProfileId = actualProvider;
|
||||
}
|
||||
|
||||
if (!this.currentApiKey) {
|
||||
throw new Error(`No API key configured for provider: ${providerId}`);
|
||||
}
|
||||
|
||||
// Update the agent's model and API key
|
||||
const baseUrl = resolveBaseUrl(actualProvider);
|
||||
const modelWithBaseUrl = baseUrl ? { ...model, baseUrl } : model;
|
||||
this.agent.setModel(modelWithBaseUrl);
|
||||
|
||||
// Update internal state
|
||||
this.resolvedProvider = providerId;
|
||||
|
||||
// Update session metadata
|
||||
this.session.saveMeta({
|
||||
provider: actualProvider,
|
||||
model: model.id,
|
||||
thinkingLevel: this.agent.state.thinkingLevel,
|
||||
reasoningMode: this.reasoningMode,
|
||||
contextWindowTokens: this.contextWindowGuard.tokens,
|
||||
});
|
||||
|
||||
return {
|
||||
provider: providerId,
|
||||
model: model.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full system prompt using the structured builder.
|
||||
* Combines profile content, tools, skills, and runtime info.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function buildSubagentSystemPrompt(params: SubagentSystemPromptParams): s
|
|||
label: params.label,
|
||||
task: params.task,
|
||||
},
|
||||
tools: params.tools,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,4 +71,6 @@ export type SubagentSystemPromptParams = {
|
|||
childSessionId: string;
|
||||
label?: string | undefined;
|
||||
task: string;
|
||||
/** Tool names available to the subagent (for tooling summary in system prompt) */
|
||||
tools?: string[] | undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,12 +15,13 @@ const TOOLS = ["read", "write", "edit", "glob", "exec", "memory_get", "memory_se
|
|||
describe("buildSystemPrompt", () => {
|
||||
// ── Full mode ─────────────────────────────────────────────────────────
|
||||
|
||||
it("full mode includes all profile sections", () => {
|
||||
it("full mode includes workspace section only (progressive disclosure)", () => {
|
||||
// Soul, user, memory are read on-demand by the agent
|
||||
const result = buildSystemPrompt({ mode: "full", profile: PROFILE });
|
||||
expect(result).toContain("# Soul");
|
||||
expect(result).toContain("# User");
|
||||
expect(result).not.toContain("# Soul");
|
||||
expect(result).not.toContain("# User");
|
||||
expect(result).toContain("# Workspace");
|
||||
expect(result).toContain("# Memory");
|
||||
expect(result).not.toContain("# Memory");
|
||||
});
|
||||
|
||||
it("full mode includes safety constitution", () => {
|
||||
|
|
@ -76,13 +77,17 @@ describe("buildSystemPrompt", () => {
|
|||
expect(result).toContain("os=darwin (arm64)");
|
||||
});
|
||||
|
||||
it("full mode includes profile directory", () => {
|
||||
it("full mode includes profile info in workspace section", () => {
|
||||
const result = buildSystemPrompt({
|
||||
mode: "full",
|
||||
profileDir: "/home/user/.super-multica/agent-profiles/test",
|
||||
profile: { workspace: "Workspace rules" },
|
||||
});
|
||||
expect(result).toContain("## Profile Directory");
|
||||
expect(result).toContain("## Profile");
|
||||
expect(result).toContain("/home/user/.super-multica/agent-profiles/test");
|
||||
expect(result).toContain("soul.md");
|
||||
expect(result).toContain("user.md");
|
||||
expect(result).toContain("memory.md");
|
||||
});
|
||||
|
||||
it("full mode excludes subagent section", () => {
|
||||
|
|
@ -242,8 +247,13 @@ describe("buildSystemPromptWithReport", () => {
|
|||
|
||||
it("report marks excluded sections correctly in minimal mode", () => {
|
||||
const { report } = buildSystemPromptWithReport({ mode: "minimal" });
|
||||
// Identity is now included in all modes (just a one-liner)
|
||||
const identity = report.sections.find((s) => s.name === "identity");
|
||||
expect(identity?.included).toBe(false);
|
||||
expect(identity?.included).toBe(true);
|
||||
|
||||
// User and memory are excluded (progressive disclosure)
|
||||
const user = report.sections.find((s) => s.name === "user");
|
||||
expect(user?.included).toBe(false);
|
||||
|
||||
const safety = report.sections.find((s) => s.name === "safety");
|
||||
expect(safety?.included).toBe(true);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): {
|
|||
const candidates: Array<{ name: string; lines: string[] }> = [
|
||||
{ name: "identity", lines: buildIdentitySection(profile, mode) },
|
||||
{ name: "user", lines: buildUserSection(profile, mode) },
|
||||
{ name: "workspace", lines: buildWorkspaceSection(profile, mode) },
|
||||
{ name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir) },
|
||||
{ name: "memory", lines: buildMemoryFileSection(profile, mode) },
|
||||
{ name: "safety", lines: buildSafetySection(includeSafety) },
|
||||
{ name: "tooling", lines: buildToolingSummary(tools, mode) },
|
||||
|
|
|
|||
|
|
@ -15,9 +15,15 @@ import {
|
|||
} from "./sections.js";
|
||||
|
||||
describe("buildIdentitySection", () => {
|
||||
it("returns soul content in full mode", () => {
|
||||
it("returns identity line in full mode (progressive disclosure)", () => {
|
||||
// Soul content is no longer injected - agent reads soul.md on demand
|
||||
const result = buildIdentitySection({ soul: "You are helpful.", config: { name: "Cleo" } }, "full");
|
||||
expect(result).toEqual(["You are Cleo, a Super Multica agent."]);
|
||||
});
|
||||
|
||||
it("returns generic identity line in full mode without name", () => {
|
||||
const result = buildIdentitySection({ soul: "You are helpful." }, "full");
|
||||
expect(result).toEqual(["You are helpful."]);
|
||||
expect(result).toEqual(["You are a Super Multica agent."]);
|
||||
});
|
||||
|
||||
it("returns identity line with name in none mode", () => {
|
||||
|
|
@ -30,47 +36,48 @@ describe("buildIdentitySection", () => {
|
|||
expect(result).toEqual(["You are a Super Multica agent."]);
|
||||
});
|
||||
|
||||
it("returns empty in minimal mode", () => {
|
||||
const result = buildIdentitySection({ soul: "data" }, "minimal");
|
||||
expect(result).toEqual([]);
|
||||
it("returns identity line in minimal mode", () => {
|
||||
const result = buildIdentitySection({ soul: "data", config: { name: "Cleo" } }, "minimal");
|
||||
expect(result).toEqual(["You are Cleo, a Super Multica agent."]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUserSection", () => {
|
||||
it("returns user content in full mode", () => {
|
||||
const result = buildUserSection({ user: "Name: Bob" }, "full");
|
||||
expect(result).toEqual(["Name: Bob"]);
|
||||
});
|
||||
|
||||
it("returns empty in minimal mode", () => {
|
||||
const result = buildUserSection({ user: "data" }, "minimal");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty when no user content", () => {
|
||||
const result = buildUserSection({}, "full");
|
||||
expect(result).toEqual([]);
|
||||
it("returns empty in all modes (progressive disclosure)", () => {
|
||||
// User content is no longer injected - agent reads user.md on demand
|
||||
expect(buildUserSection({ user: "Name: Bob" }, "full")).toEqual([]);
|
||||
expect(buildUserSection({ user: "data" }, "minimal")).toEqual([]);
|
||||
expect(buildUserSection({}, "full")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspaceSection", () => {
|
||||
it("returns workspace content in full mode", () => {
|
||||
it("returns workspace content with profile info in full mode", () => {
|
||||
const result = buildWorkspaceSection({ workspace: "Rules here" }, "full", "/path/to/profile");
|
||||
const text = result.join("\n");
|
||||
expect(text).toContain("## Profile");
|
||||
expect(text).toContain("/path/to/profile");
|
||||
expect(text).toContain("soul.md");
|
||||
expect(text).toContain("user.md");
|
||||
expect(text).toContain("memory.md");
|
||||
expect(text).toContain("Rules here");
|
||||
});
|
||||
|
||||
it("returns workspace content without profile dir", () => {
|
||||
const result = buildWorkspaceSection({ workspace: "Rules here" }, "full");
|
||||
expect(result).toEqual(["Rules here"]);
|
||||
});
|
||||
|
||||
it("returns empty in minimal mode", () => {
|
||||
expect(buildWorkspaceSection({ workspace: "data" }, "minimal")).toEqual([]);
|
||||
expect(buildWorkspaceSection({ workspace: "data" }, "minimal", "/path")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMemoryFileSection", () => {
|
||||
it("returns memory content in full mode", () => {
|
||||
const result = buildMemoryFileSection({ memory: "Key facts" }, "full");
|
||||
expect(result).toEqual(["Key facts"]);
|
||||
});
|
||||
|
||||
it("returns empty in minimal mode", () => {
|
||||
it("returns empty in all modes (progressive disclosure)", () => {
|
||||
// Memory content is no longer injected - agent reads memory.md on demand
|
||||
expect(buildMemoryFileSection({ memory: "Key facts" }, "full")).toEqual([]);
|
||||
expect(buildMemoryFileSection({ memory: "data" }, "minimal")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -227,12 +234,9 @@ describe("buildRuntimeSection", () => {
|
|||
});
|
||||
|
||||
describe("buildProfileDirSection", () => {
|
||||
it("includes path in full mode", () => {
|
||||
const result = buildProfileDirSection("/path/to/profile", "full");
|
||||
expect(result.join("\n")).toContain("/path/to/profile");
|
||||
});
|
||||
|
||||
it("returns empty in minimal mode", () => {
|
||||
it("returns empty in all modes (merged into workspace section)", () => {
|
||||
// Profile directory info is now part of buildWorkspaceSection
|
||||
expect(buildProfileDirSection("/path/to/profile", "full")).toEqual([]);
|
||||
expect(buildProfileDirSection("/path", "minimal")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,59 +47,85 @@ const TOOL_ORDER = [
|
|||
// ─── Section builders ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Identity section — soul.md in full mode, single line in none mode, nothing in minimal.
|
||||
* Identity section — brief identity line only.
|
||||
* Full profile content (soul.md) is loaded on-demand by the agent.
|
||||
*/
|
||||
export function buildIdentitySection(
|
||||
profile: ProfileContent | undefined,
|
||||
mode: SystemPromptMode,
|
||||
): string[] {
|
||||
if (mode === "none") {
|
||||
const name = profile?.config?.name;
|
||||
const name = profile?.config?.name;
|
||||
if (mode === "none" || mode === "minimal") {
|
||||
return name
|
||||
? [`You are ${name}, a Super Multica agent.`]
|
||||
: ["You are a Super Multica agent."];
|
||||
}
|
||||
if (mode === "minimal") {
|
||||
return [];
|
||||
}
|
||||
// full mode
|
||||
if (profile?.soul) {
|
||||
return [profile.soul];
|
||||
}
|
||||
// full mode - just identity line, agent reads soul.md on demand
|
||||
return name
|
||||
? [`You are ${name}, a Super Multica agent.`]
|
||||
: ["You are a Super Multica agent."];
|
||||
}
|
||||
|
||||
/**
|
||||
* User section — no longer injected into system prompt.
|
||||
* Agent reads user.md on demand from profile directory.
|
||||
*/
|
||||
export function buildUserSection(
|
||||
_profile: ProfileContent | undefined,
|
||||
_mode: SystemPromptMode,
|
||||
): string[] {
|
||||
// Progressive disclosure: agent reads user.md on demand
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* User section — user.md content (full mode only).
|
||||
*/
|
||||
export function buildUserSection(
|
||||
profile: ProfileContent | undefined,
|
||||
mode: SystemPromptMode,
|
||||
): string[] {
|
||||
if (mode !== "full" || !profile?.user) return [];
|
||||
return [profile.user];
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace section — workspace.md content (full mode only).
|
||||
* Workspace section — workspace.md content with profile directory path.
|
||||
* This is the primary profile content injected into system prompt.
|
||||
* Other profile files (soul.md, user.md, memory.md) are read on demand.
|
||||
*/
|
||||
export function buildWorkspaceSection(
|
||||
profile: ProfileContent | undefined,
|
||||
mode: SystemPromptMode,
|
||||
profileDir?: string,
|
||||
): string[] {
|
||||
if (mode !== "full" || !profile?.workspace) return [];
|
||||
return [profile.workspace];
|
||||
if (mode !== "full") return [];
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Add profile directory context first
|
||||
if (profileDir) {
|
||||
lines.push(
|
||||
"## Profile",
|
||||
"",
|
||||
`Your profile directory: \`${profileDir}\``,
|
||||
"",
|
||||
"Profile files:",
|
||||
"- `soul.md` — Your identity and values",
|
||||
"- `user.md` — Information about your user",
|
||||
"- `workspace.md` — Guidelines and conventions (below)",
|
||||
"- `memory.md` — Persistent knowledge",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
// Add workspace.md content
|
||||
if (profile?.workspace) {
|
||||
lines.push(profile.workspace);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory section — memory.md content (full mode only).
|
||||
* Memory section — no longer injected into system prompt.
|
||||
* Agent reads memory.md on demand from profile directory.
|
||||
*/
|
||||
export function buildMemoryFileSection(
|
||||
profile: ProfileContent | undefined,
|
||||
mode: SystemPromptMode,
|
||||
_profile: ProfileContent | undefined,
|
||||
_mode: SystemPromptMode,
|
||||
): string[] {
|
||||
if (mode !== "full" || !profile?.memory) return [];
|
||||
return [profile.memory];
|
||||
// Progressive disclosure: agent reads memory.md on demand
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -265,21 +291,15 @@ export function buildRuntimeSection(
|
|||
}
|
||||
|
||||
/**
|
||||
* Profile directory section — tells agent where its files live.
|
||||
* Full mode only.
|
||||
* Profile directory section — now merged into buildWorkspaceSection.
|
||||
* Kept for backwards compatibility but returns empty.
|
||||
*/
|
||||
export function buildProfileDirSection(
|
||||
profileDir: string | undefined,
|
||||
mode: SystemPromptMode,
|
||||
_profileDir: string | undefined,
|
||||
_mode: SystemPromptMode,
|
||||
): string[] {
|
||||
if (mode !== "full" || !profileDir) return [];
|
||||
return [
|
||||
"## Profile Directory",
|
||||
"",
|
||||
`Your profile files are located at: \`${profileDir}\``,
|
||||
"",
|
||||
"Use `edit` or `write` tools to update these files when needed.",
|
||||
];
|
||||
// Profile directory info is now part of workspace section
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,28 +19,22 @@ The tools system provides LLM agents with capabilities to interact with the exte
|
|||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 4-Layer Policy Filter │
|
||||
│ 3-Layer Policy Filter │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Layer 1: Profile │ │
|
||||
│ │ Base tool set: minimal | coding | web | full │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Layer 2: Global Allow/Deny │ │
|
||||
│ │ Layer 1: Global Allow/Deny │ │
|
||||
│ │ User customization via CLI or config │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Layer 3: Provider-Specific │ │
|
||||
│ │ Layer 2: Provider-Specific │ │
|
||||
│ │ Different rules for different LLM providers │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Layer 4: Subagent Restrictions │ │
|
||||
│ │ Layer 3: Subagent Restrictions │ │
|
||||
│ │ Limited tools for spawned child agents │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
|
|
@ -55,20 +49,20 @@ The tools system provides LLM agents with capabilities to interact with the exte
|
|||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Name | Description |
|
||||
| ------------- | --------------- | --------------------------------------------- |
|
||||
| Read | `read` | Read file contents |
|
||||
| Write | `write` | Write content to files |
|
||||
| Edit | `edit` | Edit existing files |
|
||||
| Glob | `glob` | Find files by pattern |
|
||||
| Exec | `exec` | Execute shell commands |
|
||||
| Process | `process` | Manage long-running processes |
|
||||
| Web Fetch | `web_fetch` | Fetch and extract content from URLs |
|
||||
| Web Search | `web_search` | Search the web (requires API key) |
|
||||
| Memory Get | `memory_get` | Retrieve a value from persistent memory |
|
||||
| Memory Set | `memory_set` | Store a value in persistent memory |
|
||||
| Memory Delete | `memory_delete` | Delete a value from persistent memory |
|
||||
| Memory List | `memory_list` | List all keys in persistent memory |
|
||||
| Tool | Name | Description |
|
||||
| ------------- | --------------- | --------------------------------------- |
|
||||
| Read | `read` | Read file contents |
|
||||
| Write | `write` | Write content to files |
|
||||
| Edit | `edit` | Edit existing files |
|
||||
| Glob | `glob` | Find files by pattern |
|
||||
| Exec | `exec` | Execute shell commands |
|
||||
| Process | `process` | Manage long-running processes |
|
||||
| Web Fetch | `web_fetch` | Fetch and extract content from URLs |
|
||||
| Web Search | `web_search` | Search the web (requires API key) |
|
||||
| Memory Get | `memory_get` | Retrieve a value from persistent memory |
|
||||
| Memory Set | `memory_set` | Store a value in persistent memory |
|
||||
| Memory Delete | `memory_delete` | Delete a value from persistent memory |
|
||||
| Memory List | `memory_list` | List all keys in persistent memory |
|
||||
|
||||
> **Note**: Memory tools require a `profileId` to be specified. They store data in the profile's memory directory.
|
||||
|
||||
|
|
@ -76,24 +70,13 @@ The tools system provides LLM agents with capabilities to interact with the exte
|
|||
|
||||
Groups provide shortcuts for allowing/denying multiple tools at once:
|
||||
|
||||
| Group | Tools |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| `group:fs` | read, write, edit, glob |
|
||||
| `group:runtime` | exec, process |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:memory` | memory_get, memory_set, memory_delete, memory_list|
|
||||
| `group:core` | All of the above (excluding memory) |
|
||||
|
||||
## Tool Profiles
|
||||
|
||||
Profiles are predefined tool sets for common use cases:
|
||||
|
||||
| Profile | Description | Tools |
|
||||
| --------- | ----------------------- | ---------------------------------- |
|
||||
| `minimal` | No tools (chat-only) | None |
|
||||
| `coding` | File system + execution | group:fs, group:runtime |
|
||||
| `web` | Coding + web access | group:fs, group:runtime, group:web |
|
||||
| `full` | No restrictions | All tools |
|
||||
| Group | Tools |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| `group:fs` | read, write, edit, glob |
|
||||
| `group:runtime` | exec, process |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:memory` | memory_get, memory_set, memory_delete, memory_list |
|
||||
| `group:core` | All of the above (excluding memory) |
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -102,11 +85,8 @@ Profiles are predefined tool sets for common use cases:
|
|||
All commands use the unified `multica` CLI (or `pnpm multica` during development).
|
||||
|
||||
```bash
|
||||
# Use a specific profile
|
||||
multica run --tools-profile coding "list files"
|
||||
|
||||
# Minimal profile with specific tools allowed
|
||||
multica run --tools-profile minimal --tools-allow exec "run ls"
|
||||
# Allow only specific tools
|
||||
multica run --tools-allow group:fs,group:runtime "list files"
|
||||
|
||||
# Deny specific tools
|
||||
multica run --tools-deny exec,process "read file.txt"
|
||||
|
|
@ -122,14 +102,11 @@ import { Agent } from './runner.js';
|
|||
|
||||
const agent = new Agent({
|
||||
tools: {
|
||||
// Layer 1: Base profile
|
||||
profile: 'coding',
|
||||
// Layer 1: Global allow/deny
|
||||
allow: ['group:fs', 'group:runtime', 'web_fetch'],
|
||||
deny: ['exec'],
|
||||
|
||||
// Layer 2: Global customization
|
||||
allow: ['web_fetch'], // Add web_fetch to coding profile
|
||||
deny: ['exec'], // But deny exec
|
||||
|
||||
// Layer 3: Provider-specific rules
|
||||
// Layer 2: Provider-specific rules
|
||||
byProvider: {
|
||||
google: {
|
||||
deny: ['exec', 'process'], // Google models can't use runtime tools
|
||||
|
|
@ -137,7 +114,7 @@ const agent = new Agent({
|
|||
},
|
||||
},
|
||||
|
||||
// Layer 4: Subagent mode
|
||||
// Layer 3: Subagent mode
|
||||
isSubagent: false,
|
||||
});
|
||||
```
|
||||
|
|
@ -150,43 +127,28 @@ Use the tools CLI to inspect and test configurations:
|
|||
# List all available tools
|
||||
multica tools list
|
||||
|
||||
# List tools after applying a profile
|
||||
multica tools list --profile coding
|
||||
# List tools with allow rules
|
||||
multica tools list --allow group:fs,group:runtime
|
||||
|
||||
# List tools with deny rules
|
||||
multica tools list --profile coding --deny exec
|
||||
multica tools list --deny exec
|
||||
|
||||
# Show all tool groups
|
||||
multica tools groups
|
||||
|
||||
# Show all profiles
|
||||
multica tools profiles
|
||||
```
|
||||
|
||||
## Policy System Details
|
||||
|
||||
### Layer 1: Profile
|
||||
### Layer 1: Global Allow/Deny
|
||||
|
||||
The profile determines the base set of available tools. If not specified, all tools are available.
|
||||
User-specified allow/deny lists:
|
||||
|
||||
```typescript
|
||||
// In groups.ts
|
||||
export const TOOL_PROFILES = {
|
||||
minimal: { allow: [] }, // No tools
|
||||
coding: { allow: ['group:fs', 'group:runtime'] }, // FS + execution
|
||||
web: { allow: ['group:fs', 'group:runtime', 'group:web'] }, // + web
|
||||
full: {}, // No restrictions
|
||||
};
|
||||
```
|
||||
- `allow`: Only these tools are available (supports group:\* syntax)
|
||||
- `deny`: These tools are blocked (takes precedence over allow)
|
||||
|
||||
### Layer 2: Global Allow/Deny
|
||||
If no `allow` list is specified, all tools are available by default.
|
||||
|
||||
User-specified allow/deny lists that modify the profile's tool set:
|
||||
|
||||
- `allow`: Only these tools are available (additive to profile)
|
||||
- `deny`: These tools are blocked (takes precedence over allow)
|
||||
|
||||
### Layer 3: Provider-Specific
|
||||
### Layer 2: Provider-Specific
|
||||
|
||||
Different LLM providers may have different capabilities or restrictions:
|
||||
|
||||
|
|
@ -199,7 +161,7 @@ Different LLM providers may have different capabilities or restrictions:
|
|||
}
|
||||
```
|
||||
|
||||
### Layer 4: Subagent Restrictions
|
||||
### Layer 3: Subagent Restrictions
|
||||
|
||||
When `isSubagent: true`, additional restrictions are applied to prevent spawned agents from accessing sensitive tools like session management.
|
||||
|
||||
|
|
@ -280,7 +242,7 @@ Tools configuration can be defined in Agent Profile's `config.json`, allowing di
|
|||
│ │ coder │ │ reviewer │ │ devops │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ tools: │ │ tools: │ │ tools: │ │
|
||||
│ │ coding │ │ minimal │ │ full │ │
|
||||
│ │ allow:fs │ │ deny:* │ │ allow:* │ │
|
||||
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────┼────────────────┼────────────────┼─────────────────────┘
|
||||
|
|
@ -296,7 +258,7 @@ Each Agent's Profile can define its own tools configuration in `config.json`:
|
|||
```json
|
||||
{
|
||||
"tools": {
|
||||
"profile": "coding",
|
||||
"allow": ["group:fs", "group:runtime"],
|
||||
"deny": ["exec"]
|
||||
},
|
||||
"provider": "anthropic",
|
||||
|
|
@ -305,28 +267,3 @@ Each Agent's Profile can define its own tools configuration in `config.json`:
|
|||
```
|
||||
|
||||
See [Profile README](../profile/README.md) for full documentation.
|
||||
|
||||
### Config Priority
|
||||
|
||||
When both Profile config and CLI options are provided:
|
||||
|
||||
1. **Profile `config.json`** - Base configuration
|
||||
2. **CLI options** - Override/extend profile settings
|
||||
|
||||
```bash
|
||||
# Profile has tools.profile = "coding"
|
||||
# CLI adds --tools-deny exec
|
||||
# Result: coding profile without exec tool
|
||||
multica run --profile my-agent --tools-deny exec "list files"
|
||||
```
|
||||
|
||||
## Future Tools
|
||||
|
||||
The following tools are planned for future implementation:
|
||||
|
||||
- **Browser** - Simplified web automation (screenshot, click, type)
|
||||
- **Session Management** - `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- **Image** - Image generation and manipulation
|
||||
- **Cron** - Scheduled task execution
|
||||
- **Message** - Inter-agent communication
|
||||
- **Canvas** - Visual output generation
|
||||
|
|
|
|||
|
|
@ -19,28 +19,22 @@
|
|||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 4 层策略过滤器 │
|
||||
│ 3 层策略过滤器 │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 第 1 层: Profile │ │
|
||||
│ │ 基础工具集: minimal | coding | web | full │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 第 2 层: 全局 Allow/Deny │ │
|
||||
│ │ 第 1 层: 全局 Allow/Deny │ │
|
||||
│ │ 通过 CLI 或配置文件进行用户自定义 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 第 3 层: Provider 特定规则 │ │
|
||||
│ │ 第 2 层: Provider 特定规则 │ │
|
||||
│ │ 不同 LLM Provider 有不同的规则 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 第 4 层: Subagent 限制 │ │
|
||||
│ │ 第 3 层: Subagent 限制 │ │
|
||||
│ │ 子 Agent 的工具访问受限 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
|
|
@ -55,20 +49,20 @@
|
|||
|
||||
## 可用工具
|
||||
|
||||
| 工具 | 名称 | 描述 |
|
||||
| ------------- | --------------- | --------------------------------------------- |
|
||||
| Read | `read` | 读取文件内容 |
|
||||
| Write | `write` | 写入文件内容 |
|
||||
| Edit | `edit` | 编辑现有文件 |
|
||||
| Glob | `glob` | 按模式查找文件 |
|
||||
| Exec | `exec` | 执行 Shell 命令 |
|
||||
| Process | `process` | 管理长时间运行的进程 |
|
||||
| Web Fetch | `web_fetch` | 从 URL 获取并提取内容 |
|
||||
| Web Search | `web_search` | 搜索网络(需要 API Key) |
|
||||
| Memory Get | `memory_get` | 从持久化内存中获取值 |
|
||||
| Memory Set | `memory_set` | 向持久化内存中存储值 |
|
||||
| Memory Delete | `memory_delete` | 从持久化内存中删除值 |
|
||||
| Memory List | `memory_list` | 列出持久化内存中的所有键 |
|
||||
| 工具 | 名称 | 描述 |
|
||||
| ------------- | --------------- | ------------------------ |
|
||||
| Read | `read` | 读取文件内容 |
|
||||
| Write | `write` | 写入文件内容 |
|
||||
| Edit | `edit` | 编辑现有文件 |
|
||||
| Glob | `glob` | 按模式查找文件 |
|
||||
| Exec | `exec` | 执行 Shell 命令 |
|
||||
| Process | `process` | 管理长时间运行的进程 |
|
||||
| Web Fetch | `web_fetch` | 从 URL 获取并提取内容 |
|
||||
| Web Search | `web_search` | 搜索网络(需要 API Key) |
|
||||
| Memory Get | `memory_get` | 从持久化内存中获取值 |
|
||||
| Memory Set | `memory_set` | 向持久化内存中存储值 |
|
||||
| Memory Delete | `memory_delete` | 从持久化内存中删除值 |
|
||||
| Memory List | `memory_list` | 列出持久化内存中的所有键 |
|
||||
|
||||
> **注意**: Memory 工具需要指定 `profileId`。数据存储在 Profile 的 memory 目录中。
|
||||
|
||||
|
|
@ -76,24 +70,13 @@
|
|||
|
||||
工具组提供了一次性允许/禁止多个工具的快捷方式:
|
||||
|
||||
| 组 | 工具 |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| `group:fs` | read, write, edit, glob |
|
||||
| `group:runtime` | exec, process |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:memory` | memory_get, memory_set, memory_delete, memory_list|
|
||||
| `group:core` | 以上所有(不包括 memory) |
|
||||
|
||||
## 工具配置文件
|
||||
|
||||
配置文件是为常见用例预定义的工具集:
|
||||
|
||||
| Profile | 描述 | 工具 |
|
||||
| --------- | ------------------- | ---------------------------------- |
|
||||
| `minimal` | 无工具(仅聊天) | 无 |
|
||||
| `coding` | 文件系统 + 执行 | group:fs, group:runtime |
|
||||
| `web` | 编码 + 网络访问 | group:fs, group:runtime, group:web |
|
||||
| `full` | 无限制 | 所有工具 |
|
||||
| 组 | 工具 |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| `group:fs` | read, write, edit, glob |
|
||||
| `group:runtime` | exec, process |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:memory` | memory_get, memory_set, memory_delete, memory_list |
|
||||
| `group:core` | 以上所有(不包括 memory) |
|
||||
|
||||
## 使用方法
|
||||
|
||||
|
|
@ -102,11 +85,8 @@
|
|||
所有命令使用统一的 `multica` CLI(开发时使用 `pnpm multica`)。
|
||||
|
||||
```bash
|
||||
# 使用特定配置文件
|
||||
multica run --tools-profile coding "list files"
|
||||
|
||||
# 最小配置文件 + 允许特定工具
|
||||
multica run --tools-profile minimal --tools-allow exec "run ls"
|
||||
# 只允许特定工具
|
||||
multica run --tools-allow group:fs,group:runtime "list files"
|
||||
|
||||
# 禁止特定工具
|
||||
multica run --tools-deny exec,process "read file.txt"
|
||||
|
|
@ -122,14 +102,11 @@ import { Agent } from './runner.js';
|
|||
|
||||
const agent = new Agent({
|
||||
tools: {
|
||||
// 第 1 层: 基础配置文件
|
||||
profile: 'coding',
|
||||
// 第 1 层: 全局 allow/deny
|
||||
allow: ['group:fs', 'group:runtime', 'web_fetch'],
|
||||
deny: ['exec'],
|
||||
|
||||
// 第 2 层: 全局自定义
|
||||
allow: ['web_fetch'], // 在 coding 配置文件基础上添加 web_fetch
|
||||
deny: ['exec'], // 但禁止 exec
|
||||
|
||||
// 第 3 层: Provider 特定规则
|
||||
// 第 2 层: Provider 特定规则
|
||||
byProvider: {
|
||||
google: {
|
||||
deny: ['exec', 'process'], // Google 模型不能使用运行时工具
|
||||
|
|
@ -137,7 +114,7 @@ const agent = new Agent({
|
|||
},
|
||||
},
|
||||
|
||||
// 第 4 层: Subagent 模式
|
||||
// 第 3 层: Subagent 模式
|
||||
isSubagent: false,
|
||||
});
|
||||
```
|
||||
|
|
@ -150,43 +127,28 @@ const agent = new Agent({
|
|||
# 列出所有可用工具
|
||||
multica tools list
|
||||
|
||||
# 列出应用配置文件后的工具
|
||||
multica tools list --profile coding
|
||||
# 列出带有允许规则的工具
|
||||
multica tools list --allow group:fs,group:runtime
|
||||
|
||||
# 列出带有禁止规则的工具
|
||||
multica tools list --profile coding --deny exec
|
||||
multica tools list --deny exec
|
||||
|
||||
# 显示所有工具组
|
||||
multica tools groups
|
||||
|
||||
# 显示所有配置文件
|
||||
multica tools profiles
|
||||
```
|
||||
|
||||
## 策略系统详情
|
||||
|
||||
### 第 1 层: Profile
|
||||
### 第 1 层: 全局 Allow/Deny
|
||||
|
||||
配置文件决定了可用工具的基础集合。如果未指定,则所有工具都可用。
|
||||
用户指定的 allow/deny 列表:
|
||||
|
||||
```typescript
|
||||
// 在 groups.ts 中
|
||||
export const TOOL_PROFILES = {
|
||||
minimal: { allow: [] }, // 无工具
|
||||
coding: { allow: ['group:fs', 'group:runtime'] }, // 文件系统 + 执行
|
||||
web: { allow: ['group:fs', 'group:runtime', 'group:web'] }, // + 网络
|
||||
full: {}, // 无限制
|
||||
};
|
||||
```
|
||||
- `allow`: 只有这些工具可用(支持 group:\* 语法)
|
||||
- `deny`: 这些工具被阻止(优先于 allow)
|
||||
|
||||
### 第 2 层: 全局 Allow/Deny
|
||||
如果未指定 `allow` 列表,默认所有工具都可用。
|
||||
|
||||
用户指定的 allow/deny 列表,用于修改配置文件的工具集:
|
||||
|
||||
- `allow`: 只有这些工具可用(在配置文件基础上添加)
|
||||
- `deny`: 这些工具被阻止(优先于 allow)
|
||||
|
||||
### 第 3 层: Provider 特定规则
|
||||
### 第 2 层: Provider 特定规则
|
||||
|
||||
不同的 LLM Provider 可能有不同的能力或限制:
|
||||
|
||||
|
|
@ -199,7 +161,7 @@ export const TOOL_PROFILES = {
|
|||
}
|
||||
```
|
||||
|
||||
### 第 4 层: Subagent 限制
|
||||
### 第 3 层: Subagent 限制
|
||||
|
||||
当 `isSubagent: true` 时,会应用额外的限制,防止子 Agent 访问敏感工具(如会话管理)。
|
||||
|
||||
|
|
@ -280,7 +242,7 @@ pnpm test src/agent/tools/policy.test.ts
|
|||
│ │ coder │ │ reviewer │ │ devops │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ tools: │ │ tools: │ │ tools: │ │
|
||||
│ │ coding │ │ minimal │ │ full │ │
|
||||
│ │ allow:fs │ │ deny:* │ │ allow:* │ │
|
||||
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────┼────────────────┼────────────────┼─────────────────────┘
|
||||
|
|
@ -296,7 +258,7 @@ pnpm test src/agent/tools/policy.test.ts
|
|||
```json
|
||||
{
|
||||
"tools": {
|
||||
"profile": "coding",
|
||||
"allow": ["group:fs", "group:runtime"],
|
||||
"deny": ["exec"]
|
||||
},
|
||||
"provider": "anthropic",
|
||||
|
|
@ -305,28 +267,3 @@ pnpm test src/agent/tools/policy.test.ts
|
|||
```
|
||||
|
||||
详见 [Profile README](../profile/README.md)。
|
||||
|
||||
### 配置优先级
|
||||
|
||||
当同时提供 Profile 配置和 CLI 选项时:
|
||||
|
||||
1. **Profile `config.json`** - 基础配置
|
||||
2. **CLI 选项** - 覆盖/扩展 Profile 设置
|
||||
|
||||
```bash
|
||||
# Profile 有 tools.profile = "coding"
|
||||
# CLI 添加 --tools-deny exec
|
||||
# 结果: coding 配置文件但没有 exec 工具
|
||||
multica run --profile my-agent --tools-deny exec "list files"
|
||||
```
|
||||
|
||||
## 未来工具
|
||||
|
||||
以下工具计划在未来实现:
|
||||
|
||||
- **Browser** - 简化的网页自动化(截图、点击、输入)
|
||||
- **Session Management** - `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- **Image** - 图像生成和处理
|
||||
- **Cron** - 定时任务执行
|
||||
- **Message** - Agent 间通信
|
||||
- **Canvas** - 可视化输出生成
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
/**
|
||||
* Tool groups and profiles for policy-based filtering.
|
||||
* Tool groups for policy-based filtering.
|
||||
*
|
||||
* Groups provide shortcuts for allowing/denying multiple tools at once.
|
||||
* Profiles are predefined tool sets for common use cases.
|
||||
* Use "group:name" in allow/deny lists.
|
||||
*/
|
||||
|
||||
export type ToolProfileId = "minimal" | "coding" | "web" | "full";
|
||||
|
||||
/**
|
||||
* Tool name aliases for compatibility.
|
||||
* Maps alternative names to canonical tool names.
|
||||
|
|
@ -51,29 +49,6 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
|||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool profiles - predefined tool sets.
|
||||
*/
|
||||
export const TOOL_PROFILES: Record<ToolProfileId, { allow?: string[]; deny?: string[] }> = {
|
||||
// Minimal: no tools (useful for chat-only agents)
|
||||
minimal: {
|
||||
allow: [],
|
||||
},
|
||||
|
||||
// Coding: file system + execution (default for coding tasks)
|
||||
coding: {
|
||||
allow: ["group:fs", "group:runtime"],
|
||||
},
|
||||
|
||||
// Web: coding + web access
|
||||
web: {
|
||||
allow: ["group:fs", "group:runtime", "group:web"],
|
||||
},
|
||||
|
||||
// Full: no restrictions
|
||||
full: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Default tools denied for subagents.
|
||||
* Subagents should not have access to session management or system tools.
|
||||
|
|
@ -118,23 +93,3 @@ export function expandToolGroups(list?: string[]): string[] {
|
|||
|
||||
return Array.from(new Set(expanded));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the policy for a profile.
|
||||
*/
|
||||
export function getProfilePolicy(
|
||||
profile?: ToolProfileId,
|
||||
): { allow?: string[]; deny?: string[] } | undefined {
|
||||
if (!profile) return undefined;
|
||||
const resolved = TOOL_PROFILES[profile];
|
||||
if (!resolved) return undefined;
|
||||
if (!resolved.allow && !resolved.deny) return undefined;
|
||||
const result: { allow?: string[]; deny?: string[] } = {};
|
||||
if (resolved.allow) {
|
||||
result.allow = [...resolved.allow];
|
||||
}
|
||||
if (resolved.deny) {
|
||||
result.deny = [...resolved.deny];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,14 @@ export { createProcessTool } from "./process.js";
|
|||
export { createGlobTool } from "./glob.js";
|
||||
export { createWebFetchTool, createWebSearchTool } from "./web/index.js";
|
||||
|
||||
// Tool groups and profiles
|
||||
// Tool groups
|
||||
export {
|
||||
type ToolProfileId,
|
||||
TOOL_NAME_ALIASES,
|
||||
TOOL_GROUPS,
|
||||
TOOL_PROFILES,
|
||||
DEFAULT_SUBAGENT_TOOL_DENY,
|
||||
normalizeToolName,
|
||||
normalizeToolList,
|
||||
expandToolGroups,
|
||||
getProfilePolicy,
|
||||
} from "./groups.js";
|
||||
|
||||
// Tool policy system
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { filterTools } from "./policy.js";
|
||||
import { TOOL_PROFILES, expandToolGroups } from "./groups.js";
|
||||
import { expandToolGroups } from "./groups.js";
|
||||
|
||||
// Mock tools for testing
|
||||
const mockTools = [
|
||||
|
|
@ -36,58 +36,12 @@ describe("tool groups", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("tool profiles", () => {
|
||||
it("minimal has empty allow", () => {
|
||||
expect(TOOL_PROFILES.minimal.allow).toEqual([]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
@ -110,6 +64,22 @@ describe("filterTools", () => {
|
|||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["read", "write"]);
|
||||
});
|
||||
|
||||
it("allow with group:* syntax", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: { allow: ["group:fs", "group:runtime"] },
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]);
|
||||
});
|
||||
|
||||
it("deny with group:* syntax", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: { deny: ["group:web"] },
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider-specific filtering", () => {
|
||||
|
|
@ -149,10 +119,10 @@ describe("subagent restrictions", () => {
|
|||
});
|
||||
|
||||
describe("combined filtering", () => {
|
||||
it("profile + deny", () => {
|
||||
it("allow + deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
profile: "coding",
|
||||
allow: ["group:fs", "group:runtime"],
|
||||
deny: ["exec"],
|
||||
},
|
||||
});
|
||||
|
|
@ -160,10 +130,10 @@ describe("combined filtering", () => {
|
|||
expect(names).toEqual(["edit", "glob", "process", "read", "write"]);
|
||||
});
|
||||
|
||||
it("profile + provider deny", () => {
|
||||
it("allow + provider deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
profile: "web",
|
||||
allow: ["group:fs", "group:runtime", "group:web"],
|
||||
byProvider: {
|
||||
google: { deny: ["exec"] },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
/**
|
||||
* Tool policy system for filtering tools based on configuration.
|
||||
*
|
||||
* Supports 4 layers of filtering:
|
||||
* 1. Profile - base tool set (minimal/coding/web/full)
|
||||
* 2. Global allow/deny - user customization
|
||||
* 3. Provider-specific - different rules for different LLM providers
|
||||
* 4. Subagent restrictions - limited tools for spawned agents
|
||||
* Supports 3 layers of filtering:
|
||||
* 1. Global allow/deny - user customization
|
||||
* 2. Provider-specific - different rules for different LLM providers
|
||||
* 3. Subagent restrictions - limited tools for spawned agents
|
||||
*/
|
||||
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
type ToolProfileId,
|
||||
expandToolGroups,
|
||||
getProfilePolicy,
|
||||
normalizeToolName,
|
||||
DEFAULT_SUBAGENT_TOOL_DENY,
|
||||
} from "./groups.js";
|
||||
|
|
@ -31,11 +28,9 @@ export interface ToolPolicy {
|
|||
* Full tool configuration from config file.
|
||||
*/
|
||||
export interface ToolsConfig {
|
||||
/** Base profile (minimal/coding/web/full) */
|
||||
profile?: ToolProfileId;
|
||||
/** Additional tools to allow */
|
||||
/** Tools to allow (supports group:* syntax) */
|
||||
allow?: string[];
|
||||
/** Tools to deny */
|
||||
/** Tools to deny (takes precedence over allow) */
|
||||
deny?: string[];
|
||||
/** Provider-specific overrides */
|
||||
byProvider?: Record<string, ToolPolicy>;
|
||||
|
|
@ -191,12 +186,11 @@ export interface FilterToolsOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* Filter tools through the 4-layer policy system.
|
||||
* Filter tools through the 3-layer policy system.
|
||||
*
|
||||
* Layer 1: Profile (base tool set)
|
||||
* Layer 2: Global allow/deny
|
||||
* Layer 3: Provider-specific
|
||||
* Layer 4: Subagent restrictions
|
||||
* Layer 1: Global allow/deny
|
||||
* Layer 2: Provider-specific
|
||||
* Layer 3: Subagent restrictions
|
||||
*/
|
||||
export function filterTools(
|
||||
tools: AgentTool<any>[],
|
||||
|
|
@ -206,15 +200,7 @@ export function filterTools(
|
|||
|
||||
let filtered = tools;
|
||||
|
||||
// Layer 1: Profile
|
||||
if (config?.profile) {
|
||||
const profilePolicy = getProfilePolicy(config.profile);
|
||||
if (profilePolicy) {
|
||||
filtered = filterToolsByPolicy(filtered, profilePolicy);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: Global allow/deny
|
||||
// Layer 1: Global allow/deny
|
||||
if (config?.allow || config?.deny) {
|
||||
const globalPolicy: ToolPolicy = {};
|
||||
if (config.allow) {
|
||||
|
|
@ -226,7 +212,7 @@ export function filterTools(
|
|||
filtered = filterToolsByPolicy(filtered, globalPolicy);
|
||||
}
|
||||
|
||||
// Layer 3: Provider-specific
|
||||
// Layer 2: Provider-specific
|
||||
if (provider && config?.byProvider) {
|
||||
const providerPolicy = resolveProviderPolicy(config.byProvider, provider);
|
||||
if (providerPolicy) {
|
||||
|
|
@ -234,7 +220,7 @@ export function filterTools(
|
|||
}
|
||||
}
|
||||
|
||||
// Layer 4: Subagent restrictions
|
||||
// Layer 3: Subagent restrictions
|
||||
if (isSubagent) {
|
||||
const subagentPolicy = getSubagentPolicy();
|
||||
filtered = filterToolsByPolicy(filtered, subagentPolicy);
|
||||
|
|
@ -246,7 +232,6 @@ export function filterTools(
|
|||
/**
|
||||
* Merge two ToolsConfig objects.
|
||||
* The override config takes precedence:
|
||||
* - profile: override wins if set
|
||||
* - allow: union of both
|
||||
* - deny: union of both
|
||||
* - byProvider: deep merge with override taking precedence
|
||||
|
|
@ -261,12 +246,6 @@ export function mergeToolsConfig(
|
|||
|
||||
const result: ToolsConfig = {};
|
||||
|
||||
// profile: override wins
|
||||
const profile = override.profile ?? base.profile;
|
||||
if (profile) {
|
||||
result.profile = profile;
|
||||
}
|
||||
|
||||
// allow: union
|
||||
const allow = mergeAllow(base.allow, override.allow);
|
||||
if (allow) {
|
||||
|
|
@ -321,15 +300,7 @@ export function wouldToolBeAllowed(
|
|||
): boolean {
|
||||
const { config, provider, isSubagent } = options;
|
||||
|
||||
// Layer 1: Profile
|
||||
if (config?.profile) {
|
||||
const profilePolicy = getProfilePolicy(config.profile);
|
||||
if (profilePolicy && !isToolAllowed(toolName, profilePolicy)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: Global allow/deny
|
||||
// Layer 1: Global allow/deny
|
||||
if (config?.allow || config?.deny) {
|
||||
const globalPolicy: ToolPolicy = {};
|
||||
if (config.allow) {
|
||||
|
|
@ -343,7 +314,7 @@ export function wouldToolBeAllowed(
|
|||
}
|
||||
}
|
||||
|
||||
// Layer 3: Provider-specific
|
||||
// Layer 2: Provider-specific
|
||||
if (provider && config?.byProvider) {
|
||||
const providerPolicy = resolveProviderPolicy(config.byProvider, provider);
|
||||
if (providerPolicy && !isToolAllowed(toolName, providerPolicy)) {
|
||||
|
|
@ -351,7 +322,7 @@ export function wouldToolBeAllowed(
|
|||
}
|
||||
}
|
||||
|
||||
// Layer 4: Subagent restrictions
|
||||
// Layer 3: Subagent restrictions
|
||||
if (isSubagent) {
|
||||
const subagentPolicy = getSubagentPolicy();
|
||||
if (!isToolAllowed(toolName, subagentPolicy)) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|||
import { getHub } from "../../hub/hub-singleton.js";
|
||||
import { buildSubagentSystemPrompt } from "../subagent/announce.js";
|
||||
import { registerSubagentRun } from "../subagent/registry.js";
|
||||
import { resolveTools } from "../tools.js";
|
||||
|
||||
const SessionsSpawnSchema = Type.Object({
|
||||
task: Type.String({ description: "The task for the subagent to perform.", minLength: 1 }),
|
||||
|
|
@ -84,12 +85,17 @@ export function createSessionsSpawnTool(
|
|||
const runId = uuidv7();
|
||||
const childSessionId = uuidv7();
|
||||
|
||||
// Resolve tools for the subagent (with isSubagent=true for policy filtering)
|
||||
const subagentTools = resolveTools({ isSubagent: true });
|
||||
const toolNames = subagentTools.map((t) => t.name);
|
||||
|
||||
// Build system prompt for the child
|
||||
const systemPrompt = buildSubagentSystemPrompt({
|
||||
requesterSessionId,
|
||||
childSessionId,
|
||||
label,
|
||||
task,
|
||||
tools: toolNames,
|
||||
});
|
||||
|
||||
// Spawn child agent via Hub
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
|
|||
// 1. Already in whitelist → pass through (reconnection, no confirmation needed)
|
||||
const allowed = ctx.deviceStore.isAllowed(from);
|
||||
if (allowed) {
|
||||
return { hubId: ctx.hubId, agentId: allowed.agentId };
|
||||
return { hubId: ctx.hubId, agentId: allowed.agentId, isNewDevice: false };
|
||||
}
|
||||
|
||||
// 2. Validate token
|
||||
|
|
@ -42,6 +42,6 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
|
|||
|
||||
// 4. User confirmed → add to whitelist (with device metadata)
|
||||
ctx.deviceStore.allowDevice(from, result.agentId, meta);
|
||||
return { hubId: ctx.hubId, agentId: result.agentId };
|
||||
return { hubId: ctx.hubId, agentId: result.agentId, isNewDevice: true };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue